Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 54 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<owner>/<repo>/.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 <archive> --repo <owner>/<repo> --signer-workflow <owner>/<repo>/.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/<owner>/<repo>/attestations/sha256:<digest>`, 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

Expand Down
212 changes: 211 additions & 1 deletion env0.plugin.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -60,12 +63,208 @@ 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}..."
# 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
_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}" || 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}"
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=""
Expand Down Expand Up @@ -135,6 +334,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
Expand Down Expand Up @@ -203,6 +404,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
Expand Down Expand Up @@ -616,7 +818,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
Loading
Loading