From c2b5bfbc7fb0a6ac89bb6020e6df6cb32737dc31 Mon Sep 17 00:00:00 2001 From: Dylan Ratcliffe Date: Wed, 6 May 2026 15:32:16 -0700 Subject: [PATCH 1/2] Added signature verification --- README.md | 58 ++++++- env0.plugin.yaml | 196 ++++++++++++++++++++++- renovate.json | 23 ++- scripts/update-cosign-pins.sh | 72 +++++++++ tests/verify-attestation.sh | 285 ++++++++++++++++++++++++++++++++++ 5 files changed, 628 insertions(+), 6 deletions(-) create mode 100755 scripts/update-cosign-pins.sh create mode 100755 tests/verify-attestation.sh diff --git a/README.md b/README.md index f1071dd..8a3b370 100644 --- a/README.md +++ b/README.md @@ -229,17 +229,67 @@ deploy: 1. **Installation**: The plugin automatically installs the latest version of the Overmind CLI and GitHub CLI (for GitHub support) to a writable directory in your PATH. GitLab support uses `curl` which is typically available on most systems. -2. **Authentication**: The API key provided in the `api_key` input is set as the `OVERMIND_API_KEY` environment variable. +2. **Supply-chain verification**: Every binary the plugin downloads is cryptographically verified against the producer workflow's GitHub Artifact Attestation (SLSA build provenance v1) before it is executed. See [Supply-chain verification](#supply-chain-verification) below. -3. **Action Execution**: Based on the `action` input, the plugin executes the corresponding Overmind/GitHub/GitLab workflow: +3. **Authentication**: The API key provided in the `api_key` input is set as the `OVERMIND_API_KEY` environment variable. + +4. **Action Execution**: Based on the `action` input, the plugin executes the corresponding Overmind/GitHub/GitLab workflow: - `submit-plan`: Uses `$ENV0_TF_PLAN_JSON` to submit the Terraform plan - `start-change`: Marks the beginning of a change with a ticket link to the env0 deployment - `end-change`: Marks the completion of a change with a ticket link to the env0 deployment - `wait-for-simulation`: Retrieves Overmind simulation results as Markdown and (when `post_comment=true`) posts them to the GitHub PR or GitLab MR per `comment_provider` (GitLab updates the comment in place). -4. **Ticket Links**: When `ENV0_PR_NUMBER` is set (i.e., the deployment is triggered by a PR/MR), the plugin constructs a stable merge request URL from `ENV0_PR_SOURCE_REPOSITORY` (or `ENV0_TEMPLATE_REPOSITORY` as a fallback) and `ENV0_PR_NUMBER`. This ensures multiple plans for the same MR update the same Overmind change. For non-PR deployments, the ticket link falls back to the env0 deployment URL. +5. **Ticket Links**: When `ENV0_PR_NUMBER` is set (i.e., the deployment is triggered by a PR/MR), the plugin constructs a stable merge request URL from `ENV0_PR_SOURCE_REPOSITORY` (or `ENV0_TEMPLATE_REPOSITORY` as a fallback) and `ENV0_PR_NUMBER`. This ensures multiple plans for the same MR update the same Overmind change. For non-PR deployments, the ticket link falls back to the env0 deployment URL. + +6. **Post-Approval Re-Plan Gating**: env0 always re-runs `terraformPlan` between approval and apply, even when the code hasn't changed. By default (`skip_after_approval: true`), the plugin detects this by checking `ENV0_REVIEWER_NAME` (set by env0 after a reviewer approves) and skips the redundant `submit-plan`. This avoids duplicate Overmind analysis and prevents `start-change` from waiting on a second analysis that no human will review. Set `skip_after_approval: false` if you need both submissions (e.g. auto-deploy environments with no prior PR plan). + +## Supply-chain verification + +The plugin downloads two binaries from public GitHub Releases at runtime — the Overmind CLI from [`overmindtech/cli`](https://github.com/overmindtech/cli) and (only for `wait-for-simulation` with `comment_provider: github`) the GitHub CLI from [`cli/cli`](https://github.com/cli/cli). Both releases publish [GitHub Artifact Attestations](https://docs.github.com/en/actions/security-for-github-actions/using-artifact-attestations/using-artifact-attestations-to-establish-provenance-for-builds) carrying [SLSA build provenance v1](https://slsa.dev/spec/v1.0/provenance). The plugin verifies the attestation of each archive **before** extracting and executing the binary inside it. + +### What the plugin checks + +For every downloaded archive: + +- The archive's SHA-256 digest is signed by Sigstore's public-good infrastructure (Fulcio cert + Rekor transparency log). +- The signing certificate's Subject Alternative Name matches `https://github.com///.github/workflows/release.yml@refs/tags/v*`. This binds the artifact to the producer workflow in the producer repository, so a release built from a fork (or from any other workflow file) fails verification. +- The OIDC issuer is `https://token.actions.githubusercontent.com`. This binds the workflow to a real GitHub Actions run. + +Specifically, the producer identities pinned by the plugin are: + +| Archive | Repo | Signer workflow | +|--------------------|--------------------|---------------------------------------| +| Overmind CLI | `overmindtech/cli` | `.github/workflows/release.yml` on a `v*` tag | +| GitHub CLI (`gh`) | `cli/cli` | `.github/workflows/release.yml` on a `v*` tag | + +### Verification path + +The plugin tries the cheaper path first and falls back to the heavier one only when needed: + +1. **`gh attestation verify` (preferred)** — if `gh` is already on `PATH` and supports `attestation verify` (GitHub CLI 2.49+), the plugin runs a single `gh attestation verify --repo / --signer-workflow //.github/workflows/release.yml --cert-oidc-issuer https://token.actions.githubusercontent.com` and is done. No new binaries are introduced on the runner. +2. **`cosign` fallback** — if `gh` is missing or too old, the plugin downloads a pinned version of [Sigstore `cosign`](https://github.com/sigstore/cosign), checks it against a pinned SHA-256 (the bootstrap trust anchor; bumped via Renovate), fetches the attestation bundle from `https://api.github.com/repos///attestations/sha256:`, and verifies it with `cosign verify-blob-attestation --new-bundle-format ...` against the same signer-workflow identity. + +The cosign fallback works on Linux/Darwin amd64+arm64 and Windows amd64 (the platforms cosign publishes binaries for). On other platforms (e.g. Linux i386), the plugin requires `gh` 2.49+ to be pre-installed on the runner and fails loudly otherwise. + +### Network requirements + +For verification to succeed, the env0 runner needs outbound HTTPS to: + +- `api.github.com` (attestation index, GitHub CLI release metadata) +- `github.com` and `objects.githubusercontent.com` (release archive + cosign download) +- `tuf-repo-cdn.sigstore.dev` and `rekor.sigstore.dev` (Sigstore trust root + transparency log) + +Setting `GH_TOKEN` (or `GITHUB_TOKEN`) raises GitHub API rate limits but is not required for verification of public-repo attestations. + +### Failure mode + +A verification failure causes the plugin to exit non-zero with a clear error message and the binary is **never** executed. **`on_failure: pass` does not bypass this** — it is reserved for runtime / API errors (e.g. Overmind unreachable), not supply-chain failures. The exit code reserved for supply-chain failures is `99`. + +If you see a verification failure on a clean runner, the most common causes are: -5. **Post-Approval Re-Plan Gating**: env0 always re-runs `terraformPlan` between approval and apply, even when the code hasn't changed. By default (`skip_after_approval: true`), the plugin detects this by checking `ENV0_REVIEWER_NAME` (set by env0 after a reviewer approves) and skips the redundant `submit-plan`. This avoids duplicate Overmind analysis and prevents `start-change` from waiting on a second analysis that no human will review. Set `skip_after_approval: false` if you need both submissions (e.g. auto-deploy environments with no prior PR plan). +1. The runner cannot reach the Sigstore endpoints listed above. +2. A new Overmind CLI release has been published without attestations (regression on the producer side — please file an issue). +3. The cosign SHA-256 pin in the plugin is stale relative to the cosign version it tries to download (Renovate is responsible for keeping these in sync; manual fix is `sh scripts/update-cosign-pins.sh` after bumping `COSIGN_VERSION`). ## Requirements diff --git a/env0.plugin.yaml b/env0.plugin.yaml index 0911bbe..435d55f 100644 --- a/env0.plugin.yaml +++ b/env0.plugin.yaml @@ -44,6 +44,9 @@ run: if [ -n "${GH_TMP_DIR}" ] && [ -d "${GH_TMP_DIR}" ]; then rm -rf "${GH_TMP_DIR}" fi + if [ -n "${COSIGN_DIR}" ] && [ -d "${COSIGN_DIR}" ]; then + rm -rf "${COSIGN_DIR}" + fi if [ -n "${MARKDOWN_FILE}" ] && [ -f "${MARKDOWN_FILE}" ]; then rm -f "${MARKDOWN_FILE}" fi @@ -60,12 +63,192 @@ run: rm -f "${GITLAB_USER_FILE}" fi } + + # --------------------------------------------------------------------- + # Supply-chain verification: every binary we download from GitHub Releases + # is verified against the producer workflow's GitHub Artifact Attestation + # (SLSA build provenance v1) before we execute it. ENG-4087. + # + # Path 1 (preferred): if `gh` (GitHub CLI 2.49+) is on PATH, use + # `gh attestation verify`. One command, no new binaries. + # Path 2 (fallback): download cosign from GitHub Releases, verify it + # against a pinned SHA256, fetch the attestation bundle from the GitHub + # API by archive digest, and run `cosign verify-blob-attestation`. + # + # Failures exit with ATTESTATION_FAIL_EXIT (99). The on_failure=pass + # wrapper at the bottom of this script does NOT swallow that code: + # supply-chain failures are intentionally non-bypassable. + # --------------------------------------------------------------------- + + ATTESTATION_FAIL_EXIT=99 + + # Pinned cosign release. Renovate keeps COSIGN_VERSION and the per-platform + # SHA256 digests in sync; refresh both together (see the customManager in + # renovate.json). + # renovate: datasource=github-releases depName=sigstore/cosign + COSIGN_VERSION="v3.0.6" + COSIGN_SHA256_linux_amd64="c956e5dfcac53d52bcf058360d579472f0c1d2d9b69f55209e256fe7783f4c74" + COSIGN_SHA256_linux_arm64="bedac92e8c3729864e13d4a17048007cfafa79d5deca993a43a90ffe018ef2b8" + COSIGN_SHA256_darwin_amd64="4c3e7af8372d3ca3296e62fa56f23fcbb5721cc6ac1827900d398f110d7cd280" + COSIGN_SHA256_darwin_arm64="5fadd012ae6381a6a29ff86a7d39aa873878852f1073fc90b15995961ecfb084" + COSIGN_SHA256_windows_amd64="9b85a88ebff2d9dd30ff4984a6f61f2cedc232dd87d81fa7f2ff3c0ed96c241c" + + attestation_fail() { + echo "ERROR: $1" >&2 + echo "Refusing to install unverified binary. Supply-chain verification failures are not bypassed by on_failure=pass." >&2 + exit "${ATTESTATION_FAIL_EXIT}" + } + + compute_sha256() { + _csha_file="$1" + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "${_csha_file}" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "${_csha_file}" | awk '{print $1}' + elif command -v openssl >/dev/null 2>&1; then + openssl dgst -sha256 "${_csha_file}" | awk '{print $NF}' + else + return 1 + fi + } + + cosign_platform_id() { + case "${OS}_${ARCH}" in + Linux_x86_64) echo "linux_amd64" ;; + Linux_arm64) echo "linux_arm64" ;; + Darwin_x86_64) echo "darwin_amd64" ;; + Darwin_arm64) echo "darwin_arm64" ;; + Windows_x86_64) echo "windows_amd64" ;; + *) return 1 ;; + esac + } + + cosign_asset_for_platform() { + case "$1" in + linux_amd64) echo "cosign-linux-amd64" ;; + linux_arm64) echo "cosign-linux-arm64" ;; + darwin_amd64) echo "cosign-darwin-amd64" ;; + darwin_arm64) echo "cosign-darwin-arm64" ;; + windows_amd64) echo "cosign-windows-amd64.exe" ;; + *) return 1 ;; + esac + } + + cosign_pinned_sha256() { + case "$1" in + linux_amd64) echo "${COSIGN_SHA256_linux_amd64}" ;; + linux_arm64) echo "${COSIGN_SHA256_linux_arm64}" ;; + darwin_amd64) echo "${COSIGN_SHA256_darwin_amd64}" ;; + darwin_arm64) echo "${COSIGN_SHA256_darwin_arm64}" ;; + windows_amd64) echo "${COSIGN_SHA256_windows_amd64}" ;; + *) return 1 ;; + esac + } + + ensure_cosign() { + if [ -n "${COSIGN_BIN}" ] && [ -x "${COSIGN_BIN}" ]; then + return 0 + fi + if ! _plat=$(cosign_platform_id); then + attestation_fail "No cosign binary published for ${OS}/${ARCH} (cosign ${COSIGN_VERSION}). Install GitHub CLI 2.49+ on the runner so the plugin can use 'gh attestation verify' instead, or run on a platform cosign supports (Linux/Darwin amd64+arm64, Windows amd64)." + fi + _asset=$(cosign_asset_for_platform "${_plat}") + _expected=$(cosign_pinned_sha256 "${_plat}") + if [ -z "${_expected}" ]; then + attestation_fail "Pinned SHA256 for cosign ${_plat} is empty; refusing to download. Update COSIGN_SHA256_${_plat} in env0.plugin.yaml." + fi + COSIGN_DIR=$(mktemp -d) + case "${_asset}" in + *.exe) COSIGN_BIN="${COSIGN_DIR}/cosign.exe" ;; + *) COSIGN_BIN="${COSIGN_DIR}/cosign" ;; + esac + _url="https://github.com/sigstore/cosign/releases/download/${COSIGN_VERSION}/${_asset}" + echo "Downloading cosign ${COSIGN_VERSION} (${_plat}) for attestation verification..." + if ! curl -fsSL "${_url}" -o "${COSIGN_BIN}"; then + attestation_fail "Failed to download cosign from ${_url}" + fi + if ! _actual=$(compute_sha256 "${COSIGN_BIN}"); then + attestation_fail "No SHA256 tool available on this runner (need sha256sum, shasum, or openssl)" + fi + if [ "${_actual}" != "${_expected}" ]; then + attestation_fail "cosign SHA256 mismatch for ${_plat} (expected ${_expected}, got ${_actual}). Pinned digest may be stale or download corrupted." + fi + chmod +x "${COSIGN_BIN}" + echo "✓ cosign ${COSIGN_VERSION} verified against pinned SHA256" + } + + verify_with_cosign() { + _vc_archive="$1"; _vc_repo="$2"; _vc_wf_path="$3" + ensure_cosign + if ! _vc_digest=$(compute_sha256 "${_vc_archive}"); then + attestation_fail "Could not compute SHA256 of ${_vc_archive}" + fi + _vc_bundle="${COSIGN_DIR}/bundle-${_vc_digest}.json" + _vc_response="${COSIGN_DIR}/response-${_vc_digest}.json" + _vc_api_url="https://api.github.com/repos/${_vc_repo}/attestations/sha256:${_vc_digest}" + _vc_gh_token="${GH_TOKEN:-${GITHUB_TOKEN:-}}" + echo "Fetching attestation bundle for ${_vc_repo} sha256:${_vc_digest}..." + if [ -n "${_vc_gh_token}" ]; then + if ! curl -fsSL \ + -H "Authorization: Bearer ${_vc_gh_token}" \ + -H "Accept: application/vnd.github+json" \ + "${_vc_api_url}" -o "${_vc_response}"; then + attestation_fail "Failed to fetch attestation bundle from ${_vc_api_url}" + fi + else + if ! curl -fsSL \ + -H "Accept: application/vnd.github+json" \ + "${_vc_api_url}" -o "${_vc_response}"; then + attestation_fail "Failed to fetch attestation bundle from ${_vc_api_url} (set GH_TOKEN to raise rate limits)" + fi + fi + if ! jq -e '.attestations | length > 0' "${_vc_response}" >/dev/null 2>&1; then + attestation_fail "No GitHub Artifact Attestations found for ${_vc_repo} digest sha256:${_vc_digest}. The producer release may not have been signed." + fi + if ! jq -r '.attestations[0].bundle' "${_vc_response}" > "${_vc_bundle}"; then + attestation_fail "Could not extract attestation bundle from GitHub API response" + fi + _vc_identity_re="^https://github\\.com/${_vc_repo}/${_vc_wf_path}@refs/tags/v.*\$" + echo "Verifying ${_vc_archive} via cosign verify-blob-attestation (${_vc_repo})..." + if ! "${COSIGN_BIN}" verify-blob-attestation \ + --bundle "${_vc_bundle}" \ + --new-bundle-format \ + --type slsaprovenance1 \ + --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ + --certificate-identity-regexp "${_vc_identity_re}" \ + "${_vc_archive}"; then + attestation_fail "cosign verify-blob-attestation failed for ${_vc_archive} (${_vc_repo})" + fi + echo "✓ Attestation verified via cosign (${_vc_repo})" + } + + verify_attestation() { + _va_archive="$1"; _va_repo="$2"; _va_wf_path="$3" + if [ ! -f "${_va_archive}" ]; then + attestation_fail "Cannot verify ${_va_archive}: file not found" + fi + if command -v gh >/dev/null 2>&1 && gh attestation verify --help >/dev/null 2>&1; then + echo "Verifying ${_va_archive} via gh attestation verify (${_va_repo})..." + if gh attestation verify "${_va_archive}" \ + --repo "${_va_repo}" \ + --signer-workflow "${_va_repo}/${_va_wf_path}" \ + --cert-oidc-issuer "https://token.actions.githubusercontent.com"; then + echo "✓ Attestation verified via gh (${_va_repo})" + return 0 + fi + attestation_fail "gh attestation verify failed for ${_va_archive} (${_va_repo})" + fi + verify_with_cosign "${_va_archive}" "${_va_repo}" "${_va_wf_path}" + } + main_script() { set -e trap cleanup EXIT TMP_DIR="" GH_TMP_DIR="" GH_BINARY="" + COSIGN_DIR="" + COSIGN_BIN="" MARKDOWN_FILE="" JSON_PAYLOAD_FILE="" GITLAB_NOTES_PAGE_FILE="" @@ -135,6 +318,8 @@ run: curl -fsSL "${DOWNLOAD_URL}" -o archive + verify_attestation "archive" "${REPO}" ".github/workflows/release.yml" + if [ "${ARCHIVE_EXT}" = "tar.gz" ]; then tar -xzf archive else @@ -203,6 +388,7 @@ run: GH_TMP_DIR=$(mktemp -d) cd "${GH_TMP_DIR}" curl -fsSL "${GH_DOWNLOAD_URL}" -o gh-archive + verify_attestation "gh-archive" "${GH_REPO}" ".github/workflows/release.yml" if [ "${GH_ARCHIVE_EXT}" = "tar.gz" ]; then tar -xzf gh-archive else @@ -616,7 +802,15 @@ run: esac } if [ "${ON_FAILURE}" = "pass" ]; then - ( main_script ) || { echo "Plugin step failed (on_failure=pass); deployment will continue."; exit 0; } + ( main_script ) + rc=$? + if [ "${rc}" -eq "${ATTESTATION_FAIL_EXIT}" ]; then + echo "Supply-chain verification failed (exit ${rc}); refusing to honor on_failure=pass." >&2 + exit "${rc}" + elif [ "${rc}" -ne 0 ]; then + echo "Plugin step failed (on_failure=pass); deployment will continue." + exit 0 + fi else main_script fi diff --git a/renovate.json b/renovate.json index 8daf615..f622980 100644 --- a/renovate.json +++ b/renovate.json @@ -2,5 +2,26 @@ "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": [ "local>overmindtech/renovate-config" - ] + ], + "customManagers": [ + { + "customType": "regex", + "description": "Bump pinned cosign version used by env0.plugin.yaml supply-chain verification. After Renovate updates COSIGN_VERSION, run scripts/update-cosign-pins.sh (or wire it as a postUpgradeTask) to refresh the per-platform SHA256 digests atomically.", + "fileMatch": ["^env0\\.plugin\\.yaml$"], + "matchStrings": [ + "# renovate: datasource=github-releases depName=sigstore/cosign\\s*\\n\\s*COSIGN_VERSION=\"(?v\\d+\\.\\d+\\.\\d+)\"" + ], + "datasourceTemplate": "github-releases", + "depNameTemplate": "sigstore/cosign" + } + ], + "postUpgradeTasks": { + "commands": [ + "scripts/update-cosign-pins.sh" + ], + "fileFilters": [ + "env0.plugin.yaml" + ], + "executionMode": "update" + } } diff --git a/scripts/update-cosign-pins.sh b/scripts/update-cosign-pins.sh new file mode 100755 index 0000000..10b11b0 --- /dev/null +++ b/scripts/update-cosign-pins.sh @@ -0,0 +1,72 @@ +#!/bin/sh +# Refresh the COSIGN_SHA256_* pins in env0.plugin.yaml to match the cosign +# version currently set in COSIGN_VERSION. Used by Renovate's postUpgradeTasks +# after a cosign version bump, and runnable manually: +# +# sh scripts/update-cosign-pins.sh +# +# Requires: curl, awk, sed. Reads cosign_checksums.txt published with each +# cosign release. +set -eu + +REPO_ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +PLUGIN="${REPO_ROOT}/env0.plugin.yaml" + +if [ ! -f "${PLUGIN}" ]; then + echo "error: ${PLUGIN} not found" >&2 + exit 1 +fi + +VERSION=$(awk -F'"' '/^[[:space:]]*COSIGN_VERSION=/{print $2; exit}' "${PLUGIN}") +if [ -z "${VERSION}" ]; then + echo "error: COSIGN_VERSION not found in ${PLUGIN}" >&2 + exit 1 +fi + +CHECKSUMS_URL="https://github.com/sigstore/cosign/releases/download/${VERSION}/cosign_checksums.txt" +echo "Fetching checksums from ${CHECKSUMS_URL}..." +CHECKSUMS=$(curl -fsSL "${CHECKSUMS_URL}") + +# extract for the cosign- filename and update the matching +# COSIGN_SHA256_="..." line in the YAML in-place. +update_pin() { + plat="$1" + asset="$2" + sha=$(printf '%s\n' "${CHECKSUMS}" | awk -v a="${asset}" '$2==a{print $1; exit}') + if [ -z "${sha}" ]; then + echo "error: ${asset} not found in cosign ${VERSION} checksums" >&2 + exit 1 + fi + echo " ${plat} -> ${sha}" + # replace the value while preserving leading whitespace and the var name. + # use a tmp file so awk -i inplace isn't required (BSD/GNU portable). + awk -v plat="${plat}" -v sha="${sha}" ' + { + pat = "^([[:space:]]*COSIGN_SHA256_" plat "=)\"[^\"]*\"(.*)$" + if (match($0, pat)) { + # rebuild line with new SHA + # split into prefix and suffix manually since match()/regex + # replacement varies between awk implementations. + printf("%sCOSIGN_SHA256_%s=\"%s\"\n", _leading_ws($0), plat, sha) + } else { + print + } + } + function _leading_ws(line, i, c) { + for (i = 1; i <= length(line); i++) { + c = substr(line, i, 1) + if (c != " " && c != "\t") return substr(line, 1, i - 1) + } + return line + } + ' "${PLUGIN}" > "${PLUGIN}.tmp" + mv "${PLUGIN}.tmp" "${PLUGIN}" +} + +update_pin linux_amd64 cosign-linux-amd64 +update_pin linux_arm64 cosign-linux-arm64 +update_pin darwin_amd64 cosign-darwin-amd64 +update_pin darwin_arm64 cosign-darwin-arm64 +update_pin windows_amd64 cosign-windows-amd64.exe + +echo "✓ Updated COSIGN_SHA256_* pins in ${PLUGIN} to match ${VERSION}" diff --git a/tests/verify-attestation.sh b/tests/verify-attestation.sh new file mode 100755 index 0000000..5b58179 --- /dev/null +++ b/tests/verify-attestation.sh @@ -0,0 +1,285 @@ +#!/bin/sh +# Unit tests for the verify_attestation helper in env0.plugin.yaml. +# +# Exercises the helper against real GitHub Artifact Attestations on the +# Sigstore public-good instance. Expects outbound HTTPS to api.github.com, +# objects.githubusercontent.com, tuf-repo-cdn.sigstore.dev, rekor.sigstore.dev. +# +# Run with: sh tests/verify-attestation.sh +# +# Requires: yq, jq, curl, awk, sha256sum or shasum, plus either `gh` (>= 2.49) +# on PATH or a network-reachable cosign download path. +set -eu + +REPO_ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +PLUGIN="${REPO_ROOT}/env0.plugin.yaml" + +TESTS_RUN=0 +TESTS_PASSED=0 +TESTS_FAILED=0 +FAILED_NAMES="" + +note() { printf '\n=== %s ===\n' "$*"; } +pass() { TESTS_PASSED=$((TESTS_PASSED + 1)); printf ' PASS: %s\n' "$*"; } +fail() { TESTS_FAILED=$((TESTS_FAILED + 1)); FAILED_NAMES="${FAILED_NAMES} $1"; printf ' FAIL: %s\n' "$*"; } + +# assert_rc +assert_rc() { + TESTS_RUN=$((TESTS_RUN + 1)) + if [ "$1" = "$2" ]; then + pass "$3 (rc=$2)" + else + fail "$3 (expected rc=$1, got rc=$2)" + fi +} + +# require_cmd ... +require_cmd() { + for c in "$@"; do + if ! command -v "$c" >/dev/null 2>&1; then + echo "fatal: missing required tool: $c" >&2 + exit 2 + fi + done +} + +require_cmd yq jq curl awk +if ! command -v sha256sum >/dev/null 2>&1 \ + && ! command -v shasum >/dev/null 2>&1 \ + && ! command -v openssl >/dev/null 2>&1; then + echo "fatal: need one of sha256sum, shasum, or openssl" >&2 + exit 2 +fi + +# --------------------------------------------------------------------------- +# Extract the helper functions from the plugin YAML and source them into a +# subshell. We slice from `ATTESTATION_FAIL_EXIT=` (the first module-level +# constant we add) to the start of `main_script() {`, then strip the +# main_script line itself. This mirrors what the smoke section of the plan +# describes and what validate-shell.yml does for shellcheck. +# --------------------------------------------------------------------------- +EXTRACTED=$(mktemp) +trap 'rm -f "${EXTRACTED}"' EXIT +yq eval -r '.run.exec' "${PLUGIN}" \ + | awk '/^[[:space:]]*ATTESTATION_FAIL_EXIT=/,/^[[:space:]]*main_script\(\) \{/' \ + | sed '/^[[:space:]]*main_script() {/d' > "${EXTRACTED}" + +if [ ! -s "${EXTRACTED}" ]; then + echo "fatal: could not extract verify_attestation helpers from ${PLUGIN}" >&2 + exit 2 +fi + +# Map host OS/ARCH onto the same labels env0.plugin.yaml uses, so the helper +# can decide which cosign asset to download. +host_os=$(uname -s) +host_arch=$(uname -m) +case "${host_arch}" in + x86_64|amd64) host_arch="x86_64" ;; + aarch64|arm64) host_arch="arm64" ;; + i386|i686) host_arch="i386" ;; +esac + +# Build a PATH that does NOT contain `gh`, used by the cosign-fallback tests. +NO_GH_PATH="" +old_ifs="${IFS}"; IFS=: +for p in ${PATH}; do + [ -z "${p}" ] && continue + [ -x "${p}/gh" ] && continue + if [ -z "${NO_GH_PATH}" ]; then NO_GH_PATH="${p}"; else NO_GH_PATH="${NO_GH_PATH}:${p}"; fi +done +IFS="${old_ifs}" + +# --------------------------------------------------------------------------- +# Per-test sandbox helpers +# --------------------------------------------------------------------------- +overmind_filename_for_host() { + case "${host_os}_${host_arch}" in + Linux_x86_64) echo "overmind_cli_Linux_x86_64.tar.gz" ;; + Linux_arm64) echo "overmind_cli_Linux_arm64.tar.gz" ;; + Darwin_x86_64) echo "overmind_cli_Darwin_x86_64.tar.gz" ;; + Darwin_arm64) echo "overmind_cli_Darwin_arm64.tar.gz" ;; + *) return 1 ;; + esac +} + +OVERMIND_FILE=$(overmind_filename_for_host) || { + echo "fatal: no published Overmind CLI archive for host ${host_os}/${host_arch}" >&2 + exit 2 +} + +# Download once and cache for the duration of the test run. +SHARED_DIR=$(mktemp -d) +trap 'rm -rf "${SHARED_DIR}" "${EXTRACTED}"' EXIT +echo "Downloading ${OVERMIND_FILE}..." +curl -fsSL "https://github.com/overmindtech/cli/releases/latest/download/${OVERMIND_FILE}" \ + -o "${SHARED_DIR}/overmind-archive" + +# Tiny gh asset for the cross-repo test. We don't need to install gh, just +# verify a real cli/cli archive exists and is attested. Use linux_amd64 because +# it's small and we never extract it. +GH_TAG=$(curl -fsSL https://api.github.com/repos/cli/cli/releases/latest | jq -r '.tag_name') +GH_VERSION=${GH_TAG#v} +echo "Downloading gh ${GH_TAG} (linux amd64) for cross-repo test..." +curl -fsSL "https://github.com/cli/cli/releases/download/${GH_TAG}/gh_${GH_VERSION}_linux_amd64.tar.gz" \ + -o "${SHARED_DIR}/gh-archive" + +# Unique fixture for the missing-attestation test: 64 random bytes that GitHub +# has never seen, so the attestations endpoint returns an empty list. +dd if=/dev/urandom bs=1 count=64 of="${SHARED_DIR}/random-fixture" 2>/dev/null + +# --------------------------------------------------------------------------- +# Per-test helper: run a verify_attestation invocation in a clean subshell +# with controlled PATH and OS/ARCH, capture its rc. stderr/stdout pass through +# so failure messages are visible. +# --------------------------------------------------------------------------- +run_verify() { + # args: + # path-mode: "default" (current PATH) or "no-gh" (PATH stripped of gh) + _archive="$1"; _repo="$2"; _wf="$3"; _path_mode="$4" + case "${_path_mode}" in + default) _path="${PATH}" ;; + no-gh) _path="${NO_GH_PATH}" ;; + *) echo "bad path-mode: ${_path_mode}" >&2; return 2 ;; + esac + set +e + PATH="${_path}" \ + OS="${host_os}" \ + ARCH="${host_arch}" \ + sh -c '. "$1"; verify_attestation "$2" "$3" "$4"' _ "${EXTRACTED}" "${_archive}" "${_repo}" "${_wf}" + _rc=$? + set -e + return "${_rc}" +} + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +# Skip cosign tests if the host platform doesn't have a published cosign binary. +host_has_cosign() { + case "${host_os}_${host_arch}" in + Linux_x86_64|Linux_arm64|Darwin_x86_64|Darwin_arm64) return 0 ;; + *) return 1 ;; + esac +} + +note "1/7 gh happy path — Overmind CLI archive verifies via gh" +if command -v gh >/dev/null 2>&1; then + cp "${SHARED_DIR}/overmind-archive" "${SHARED_DIR}/t1-archive" + run_verify "${SHARED_DIR}/t1-archive" "overmindtech/cli" ".github/workflows/release.yml" default || rc=$? + rc=${rc:-0} + assert_rc 0 "${rc}" "gh happy path" + unset rc +else + echo " SKIP: gh not on PATH" +fi + +note "2/7 cosign fallback — Overmind CLI archive verifies via cosign when gh absent" +if host_has_cosign; then + cp "${SHARED_DIR}/overmind-archive" "${SHARED_DIR}/t2-archive" + run_verify "${SHARED_DIR}/t2-archive" "overmindtech/cli" ".github/workflows/release.yml" no-gh || rc=$? + rc=${rc:-0} + assert_rc 0 "${rc}" "cosign fallback happy path" + unset rc +else + echo " SKIP: no published cosign binary for ${host_os}/${host_arch}" +fi + +note "3/7 tamper detection — flipped byte must fail (gh path)" +if command -v gh >/dev/null 2>&1; then + cp "${SHARED_DIR}/overmind-archive" "${SHARED_DIR}/t3-archive" + printf '\x00' >> "${SHARED_DIR}/t3-archive" + run_verify "${SHARED_DIR}/t3-archive" "overmindtech/cli" ".github/workflows/release.yml" default || rc=$? + rc=${rc:-0} + assert_rc 99 "${rc}" "tamper detection (gh)" + unset rc +else + echo " SKIP: gh not on PATH" +fi + +note "4/7 tamper detection — flipped byte must fail (cosign path)" +if host_has_cosign; then + cp "${SHARED_DIR}/overmind-archive" "${SHARED_DIR}/t4-archive" + printf '\x00' >> "${SHARED_DIR}/t4-archive" + run_verify "${SHARED_DIR}/t4-archive" "overmindtech/cli" ".github/workflows/release.yml" no-gh || rc=$? + rc=${rc:-0} + assert_rc 99 "${rc}" "tamper detection (cosign)" + unset rc +else + echo " SKIP: no published cosign binary for ${host_os}/${host_arch}" +fi + +note "5/7 wrong signer-workflow — fake workflow path must fail" +if command -v gh >/dev/null 2>&1; then + cp "${SHARED_DIR}/overmind-archive" "${SHARED_DIR}/t5-archive" + run_verify "${SHARED_DIR}/t5-archive" "overmindtech/cli" ".github/workflows/notreal.yml" default || rc=$? + rc=${rc:-0} + assert_rc 99 "${rc}" "wrong signer-workflow (gh) — proves identity pin works" + unset rc +else + echo " SKIP: gh not on PATH" +fi + +note "6/7 wrong repo — verifying cli/cli archive as overmindtech/cli must fail" +if command -v gh >/dev/null 2>&1; then + cp "${SHARED_DIR}/gh-archive" "${SHARED_DIR}/t6-archive" + run_verify "${SHARED_DIR}/t6-archive" "overmindtech/cli" ".github/workflows/release.yml" default || rc=$? + rc=${rc:-0} + assert_rc 99 "${rc}" "cross-repo confusion (gh)" + unset rc +else + echo " SKIP: gh not on PATH" +fi + +note "7/7 missing attestation — random fixture has no attestation, must fail (cosign path)" +if host_has_cosign; then + cp "${SHARED_DIR}/random-fixture" "${SHARED_DIR}/t7-archive" + run_verify "${SHARED_DIR}/t7-archive" "overmindtech/cli" ".github/workflows/release.yml" no-gh || rc=$? + rc=${rc:-0} + assert_rc 99 "${rc}" "missing attestation (cosign)" + unset rc +else + echo " SKIP: no published cosign binary for ${host_os}/${host_arch}" +fi + +# --------------------------------------------------------------------------- +# Bonus: verify that on_failure=pass does NOT swallow rc=99. This wraps a +# guaranteed-failing call inside the same subshell pattern the script trailer +# uses and asserts that 99 propagates. +# --------------------------------------------------------------------------- +note "bonus: on_failure=pass cannot swallow rc=99" +cp "${SHARED_DIR}/overmind-archive" "${SHARED_DIR}/tb-archive" +# Use a guaranteed-failing call (wrong signer-workflow) and confirm that the +# same wrapper logic the script trailer uses propagates rc=99 instead of +# converting it to rc=0 the way it would for any other failure. +set +e +TB_ARCHIVE="${SHARED_DIR}/tb-archive" \ +TB_REPO="overmindtech/cli" \ +TB_WF=".github/workflows/notreal.yml" \ +TB_HELPERS="${EXTRACTED}" \ +PATH="${PATH}" OS="${host_os}" ARCH="${host_arch}" sh -c ' +. "${TB_HELPERS}" +main_script() { verify_attestation "${TB_ARCHIVE}" "${TB_REPO}" "${TB_WF}"; } +( main_script ) +rc=$? +if [ "${rc}" -eq "${ATTESTATION_FAIL_EXIT}" ]; then + exit "${rc}" +elif [ "${rc}" -ne 0 ]; then + # This is the line we are testing: any *other* failure becomes rc=0 + # under on_failure=pass. We assert that we DON''T reach here for rc=99. + exit 0 +fi +' +rc=$? +set -e +assert_rc 99 "${rc}" "on_failure=pass non-bypass" + +# --------------------------------------------------------------------------- +echo +echo "============================================================" +printf 'Total: %d Pass: %d Fail: %d\n' "${TESTS_RUN}" "${TESTS_PASSED}" "${TESTS_FAILED}" +if [ "${TESTS_FAILED}" -gt 0 ]; then + printf 'Failed:%s\n' "${FAILED_NAMES}" + exit 1 +fi +echo "All tests passed." From 1fc61a0845bc22d5b308686fdf0b0a56e285b0f6 Mon Sep 17 00:00:00 2001 From: Dylan Ratcliffe Date: Wed, 6 May 2026 15:39:47 -0700 Subject: [PATCH 2/2] Fall back to unauth attestation fetch on 401/403 env0 runners often expose a fine-grained PAT as GH_TOKEN scoped only to the customer's own repos. Sending it to the public attestations endpoint for overmindtech/cli triggers 401, which our cosign-fallback path treated as fatal. Retry without auth on 401/403 since attestations on public repos are publicly readable; cryptographic verification of the returned bundle remains the trust boundary. Adds a unit test (8/8) reproducing the env0 failure mode with a bogus GH_TOKEN and asserting verification still succeeds. Co-authored-by: Cursor --- env0.plugin.yaml | 28 ++++++++++++++++++++++------ tests/verify-attestation.sh | 35 ++++++++++++++++++++++++++++------- 2 files changed, 50 insertions(+), 13 deletions(-) diff --git a/env0.plugin.yaml b/env0.plugin.yaml index 435d55f..113aad8 100644 --- a/env0.plugin.yaml +++ b/env0.plugin.yaml @@ -188,18 +188,34 @@ run: _vc_api_url="https://api.github.com/repos/${_vc_repo}/attestations/sha256:${_vc_digest}" _vc_gh_token="${GH_TOKEN:-${GITHUB_TOKEN:-}}" echo "Fetching attestation bundle for ${_vc_repo} sha256:${_vc_digest}..." + # Try authenticated first (raises rate limits) but fall back to + # unauthenticated on 401/403. env0 runners often expose a fine-grained + # PAT scoped only to the customer's own repos, which would 401 here; + # the attestations endpoint is publicly readable for public repos and + # the actual trust boundary is the cosign verify step below. + _vc_authed_ok=0 if [ -n "${_vc_gh_token}" ]; then - if ! curl -fsSL \ + _vc_http=$(curl -sSL -o "${_vc_response}" -w '%{http_code}' \ -H "Authorization: Bearer ${_vc_gh_token}" \ -H "Accept: application/vnd.github+json" \ - "${_vc_api_url}" -o "${_vc_response}"; then - attestation_fail "Failed to fetch attestation bundle from ${_vc_api_url}" - fi - else + "${_vc_api_url}" || echo "000") + case "${_vc_http}" in + 200) + _vc_authed_ok=1 + ;; + 401|403) + echo " GH_TOKEN/GITHUB_TOKEN was rejected (HTTP ${_vc_http}); retrying without auth since attestations on public repos are publicly readable." >&2 + ;; + *) + attestation_fail "Failed to fetch attestation bundle from ${_vc_api_url} (HTTP ${_vc_http})" + ;; + esac + fi + if [ "${_vc_authed_ok}" -eq 0 ]; then if ! curl -fsSL \ -H "Accept: application/vnd.github+json" \ "${_vc_api_url}" -o "${_vc_response}"; then - attestation_fail "Failed to fetch attestation bundle from ${_vc_api_url} (set GH_TOKEN to raise rate limits)" + attestation_fail "Failed to fetch attestation bundle from ${_vc_api_url}" fi fi if ! jq -e '.attestations | length > 0' "${_vc_response}" >/dev/null 2>&1; then diff --git a/tests/verify-attestation.sh b/tests/verify-attestation.sh index 5b58179..ff3cbfd 100755 --- a/tests/verify-attestation.sh +++ b/tests/verify-attestation.sh @@ -163,7 +163,7 @@ host_has_cosign() { esac } -note "1/7 gh happy path — Overmind CLI archive verifies via gh" +note "1/8 gh happy path — Overmind CLI archive verifies via gh" if command -v gh >/dev/null 2>&1; then cp "${SHARED_DIR}/overmind-archive" "${SHARED_DIR}/t1-archive" run_verify "${SHARED_DIR}/t1-archive" "overmindtech/cli" ".github/workflows/release.yml" default || rc=$? @@ -174,7 +174,7 @@ else echo " SKIP: gh not on PATH" fi -note "2/7 cosign fallback — Overmind CLI archive verifies via cosign when gh absent" +note "2/8 cosign fallback — Overmind CLI archive verifies via cosign when gh absent" if host_has_cosign; then cp "${SHARED_DIR}/overmind-archive" "${SHARED_DIR}/t2-archive" run_verify "${SHARED_DIR}/t2-archive" "overmindtech/cli" ".github/workflows/release.yml" no-gh || rc=$? @@ -185,7 +185,7 @@ else echo " SKIP: no published cosign binary for ${host_os}/${host_arch}" fi -note "3/7 tamper detection — flipped byte must fail (gh path)" +note "3/8 tamper detection — flipped byte must fail (gh path)" if command -v gh >/dev/null 2>&1; then cp "${SHARED_DIR}/overmind-archive" "${SHARED_DIR}/t3-archive" printf '\x00' >> "${SHARED_DIR}/t3-archive" @@ -197,7 +197,7 @@ else echo " SKIP: gh not on PATH" fi -note "4/7 tamper detection — flipped byte must fail (cosign path)" +note "4/8 tamper detection — flipped byte must fail (cosign path)" if host_has_cosign; then cp "${SHARED_DIR}/overmind-archive" "${SHARED_DIR}/t4-archive" printf '\x00' >> "${SHARED_DIR}/t4-archive" @@ -209,7 +209,7 @@ else echo " SKIP: no published cosign binary for ${host_os}/${host_arch}" fi -note "5/7 wrong signer-workflow — fake workflow path must fail" +note "5/8 wrong signer-workflow — fake workflow path must fail" if command -v gh >/dev/null 2>&1; then cp "${SHARED_DIR}/overmind-archive" "${SHARED_DIR}/t5-archive" run_verify "${SHARED_DIR}/t5-archive" "overmindtech/cli" ".github/workflows/notreal.yml" default || rc=$? @@ -220,7 +220,7 @@ else echo " SKIP: gh not on PATH" fi -note "6/7 wrong repo — verifying cli/cli archive as overmindtech/cli must fail" +note "6/8 wrong repo — verifying cli/cli archive as overmindtech/cli must fail" if command -v gh >/dev/null 2>&1; then cp "${SHARED_DIR}/gh-archive" "${SHARED_DIR}/t6-archive" run_verify "${SHARED_DIR}/t6-archive" "overmindtech/cli" ".github/workflows/release.yml" default || rc=$? @@ -231,7 +231,7 @@ else echo " SKIP: gh not on PATH" fi -note "7/7 missing attestation — random fixture has no attestation, must fail (cosign path)" +note "7/8 missing attestation — random fixture has no attestation, must fail (cosign path)" if host_has_cosign; then cp "${SHARED_DIR}/random-fixture" "${SHARED_DIR}/t7-archive" run_verify "${SHARED_DIR}/t7-archive" "overmindtech/cli" ".github/workflows/release.yml" no-gh || rc=$? @@ -242,6 +242,27 @@ else echo " SKIP: no published cosign binary for ${host_os}/${host_arch}" fi +note "8/8 GH_TOKEN rejected (401) — must fall back to unauth and still verify (cosign path)" +# Reproduces the env0 runner case where a fine-grained PAT scoped only to the +# customer's own repos is in the environment as GH_TOKEN. The attestations +# endpoint returns 401 for the bad bearer; we must retry without auth. +if host_has_cosign; then + cp "${SHARED_DIR}/overmind-archive" "${SHARED_DIR}/t8-archive" + set +e + PATH="${NO_GH_PATH}" \ + OS="${host_os}" \ + ARCH="${host_arch}" \ + GH_TOKEN="ghp_definitely_not_a_real_token_xxxxxxxxxxxxxxxxxxxx" \ + sh -c '. "$1"; verify_attestation "$2" "$3" "$4"' \ + _ "${EXTRACTED}" "${SHARED_DIR}/t8-archive" "overmindtech/cli" ".github/workflows/release.yml" + rc=$? + set -e + assert_rc 0 "${rc}" "bad GH_TOKEN falls back to unauth and verifies" + unset rc +else + echo " SKIP: no published cosign binary for ${host_os}/${host_arch}" +fi + # --------------------------------------------------------------------------- # Bonus: verify that on_failure=pass does NOT swallow rc=99. This wraps a # guaranteed-failing call inside the same subshell pattern the script trailer