You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by vi...@apache.org on 2026/08/19 14:41:24 UTC

(superset-kubernetes-operator) branch main updated: fix(release): harden release-pipeline integrity (#280)

This is an automated email from the ASF dual-hosted git repository.

villebro pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/superset-kubernetes-operator.git


The following commit(s) were added to refs/heads/main by this push:
     new 5bd25bf  fix(release): harden release-pipeline integrity (#280)
5bd25bf is described below

commit 5bd25bfbcc49249fe11174c4f4008783140b31c9
Author: Ville Brofeldt <33...@users.noreply.github.com>
AuthorDate: Wed Aug 19 07:41:19 2026 -0700

    fix(release): harden release-pipeline integrity (#280)
    
    Four independent release/supply-chain gaps in the build and signing pipeline:
    
    - Helm chart signed by mutable tag (TOCTOU): the sign step reconstructed a
      tag reference from Chart.yaml after helm push discarded the digest, so cosign
      signed whatever the registry served at signing time — a window in which a
      packages:write credential could swap the chart and receive a valid keyless
      signature. The push step now captures and validates the pushed digest and the
      chart is signed by digest, mirroring the image step.
    
    - Release image build consumed the GHA BuildKit cache: cache-from/to type=gha
      fed the cosign-signed, SLSA-attested build, so a poisoned default-branch-scope
      cache entry could be laundered into a signed release. Cache is now gated on
      github.ref_type != 'tag', so tag-triggered release builds are hermetic while
      dev builds keep the cache.
    
    - Documented cosign-verify commands were unpinned/permissive: security.md used a
      bare verify with no identity, and downloads.md used an unanchored repo-wide
      identity regexp that any workflow/ref satisfies. Both now pin the issuer and an
      anchored release.yml@refs/tags/v* identity, with explicit text that dev builds
      share the identity and must fail the check.
    
    - Finalize could promote a non-voted RC tarball: release-source.sh globbed
      -rc*.tar.gz and took head -1 of unsorted find output, ignoring the RC number
      derived from the tag on HEAD. It now selects the exact voted rc${RC} tarball
      (validated integer) and errors on ambiguity.
    
    Found via a Claude security scan.
    
    Generated-by: Claude
---
 .github/workflows/release.yml | 31 ++++++++++++++++++++++++++-----
 docs/reference/downloads.md   |  6 ++++--
 docs/reference/security.md    |  9 ++++++++-
 scripts/release-source.sh     | 28 +++++++++++++++++++++-------
 4 files changed, 59 insertions(+), 15 deletions(-)

diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 1ee345b..cca227e 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -112,8 +112,15 @@ jobs:
           annotations: ${{ steps.meta.outputs.annotations }}
           sbom: true
           provenance: mode=max
-          cache-from: type=gha
-          cache-to: type=gha,mode=max
+          # Tag (release) builds must be hermetic: the GHA BuildKit cache is
+          # writable by any run in the default-branch scope and is not
+          # content-addressed against source, so a poisoned cache entry would
+          # be silently laundered into the image that is then cosign-signed
+          # and shipped with SLSA provenance. Cache is therefore used only for
+          # throwaway main/workflow_dispatch dev builds; tag-triggered builds
+          # rebuild every layer from source (accepting the extra build time).
+          cache-from: ${{ github.ref_type != 'tag' && 'type=gha' || '' }}
+          cache-to: ${{ github.ref_type != 'tag' && 'type=gha,mode=max' || '' }}
 
       - name: Install cosign
         uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
@@ -148,8 +155,18 @@ jobs:
           helm package charts/superset-operator
 
       - name: Push Helm chart to GHCR OCI
+        id: chart-push
         run: |
-          helm push superset-operator-*.tgz ${{ env.HELM_REGISTRY }}
+          # Capture the digest helm push reports so the signing step below
+          # signs exactly the bytes this workflow produced — not whatever the
+          # mutable version tag happens to resolve to at signing time.
+          helm push superset-operator-*.tgz ${{ env.HELM_REGISTRY }} 2>&1 | tee helm-push.log
+          DIGEST="$(awk '$1 == "Digest:" {print $2}' helm-push.log)"
+          if ! [[ "${DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]]; then
+            echo "Failed to capture chart digest from helm push output" >&2
+            exit 1
+          fi
+          echo "digest=${DIGEST}" >> "${GITHUB_OUTPUT}"
 
       - name: Push Artifact Hub repository metadata
         run: |
@@ -160,6 +177,10 @@ jobs:
 
       - name: Sign Helm chart with cosign
         run: |
-          CHART_VERSION="$(grep '^version:' charts/superset-operator/Chart.yaml | awk '{print $2}')"
-          CHART_REF="${{ env.HELM_REGISTRY }}/superset-operator:${CHART_VERSION}"
+          # Sign by immutable digest, mirroring the image signing step above.
+          # Signing a tag reference is a TOCTOU hazard: cosign resolves the
+          # tag at signing time, so anyone with registry write access could
+          # swap the chart between push and sign and receive a valid keyless
+          # signature under this workflow's identity.
+          CHART_REF="${{ env.HELM_REGISTRY }}/superset-operator@${{ steps.chart-push.outputs.digest }}"
           cosign sign --yes "${CHART_REF#oci://}"
diff --git a/docs/reference/downloads.md b/docs/reference/downloads.md
index 9cd557b..b0c46e2 100644
--- a/docs/reference/downloads.md
+++ b/docs/reference/downloads.md
@@ -78,15 +78,17 @@ docker pull ghcr.io/apache/superset-kubernetes-operator:dev
 
 ### Image Signing
 
-All images are signed with [cosign](https://docs.sigstore.dev/cosign/overview/) using keyless OIDC signing via GitHub Actions. To verify:
+All images are signed with [cosign](https://docs.sigstore.dev/cosign/overview/) using keyless OIDC signing via GitHub Actions. The release workflow also signs throwaway dev builds (pushes to `main` and `workflow_dispatch` runs) under the same workflow identity, and those builds are **not** gated on release CI — so the verification command must pin the identity to the tag-triggered release ref, not just the repository. To verify a release:
 
 ```bash
 cosign verify \
   --certificate-oidc-issuer https://token.actions.githubusercontent.com \
-  --certificate-identity-regexp 'github\.com/apache/superset-kubernetes-operator' \
+  --certificate-identity-regexp '^https://github\.com/apache/superset-kubernetes-operator/\.github/workflows/release\.yml@refs/tags/v.*$' \
   ghcr.io/apache/superset-kubernetes-operator:<tag>
 ```
 
+Images whose signing certificate identity ends in `@refs/heads/main` are ungated development builds; do not accept them as releases.
+
 ## Helm Chart
 
 The Helm chart is published as an OCI artifact to the GitHub Container Registry on every merge to `main` and on version tags. It is also indexed on [Artifact Hub](https://artifacthub.io/packages/helm/superset-kubernetes-operator/superset-operator).
diff --git a/docs/reference/security.md b/docs/reference/security.md
index a30d1a3..2cf68d7 100644
--- a/docs/reference/security.md
+++ b/docs/reference/security.md
@@ -182,7 +182,14 @@ The release pipeline produces signed multi-architecture artifacts:
 
 - **Base images:** the build stage uses a fully-qualified Go patch tag and the runtime stage uses [`gcr.io/distroless/static:nonroot`](https://github.com/GoogleContainerTools/distroless) — no shell, no package manager, no unnecessary binaries. The Dockerfile pins both by digest and Renovate keeps them current (digest refreshes are applied without the version soak — see the dependency policy below).
 - **Architectures:** `linux/amd64` and `linux/arm64` are built and signed identically.
-- **Image signatures:** The release workflow signs the manager image and the packaged Helm chart with [Cosign](https://github.com/sigstore/cosign) using GitHub Actions OIDC (keyless). Verify with `cosign verify ghcr.io/apache/superset-kubernetes-operator@<digest>`.
+- **Image signatures:** The release workflow signs the manager image and the packaged Helm chart with [Cosign](https://github.com/sigstore/cosign) using GitHub Actions OIDC (keyless). The same workflow identity also signs throwaway dev builds (pushes to `main` and `workflow_dispatch` runs), which are **not** gated on release CI, so verification must pin the release-tag signing identity — a permissive identity match would also accept an ungated dev build. Verify releases with:
+
+        cosign verify \
+          --certificate-oidc-issuer https://token.actions.githubusercontent.com \
+          --certificate-identity-regexp '^https://github\.com/apache/superset-kubernetes-operator/\.github/workflows/release\.yml@refs/tags/v.*$' \
+          ghcr.io/apache/superset-kubernetes-operator@<digest>
+
+    The `@refs/tags/v*` constraint in the certificate identity is what binds the signature to a CI-gated release build: images signed from `refs/heads/main` are development builds and must not be treated as releases, even though their signatures are otherwise valid. Verify the signed Helm chart the same way against `ghcr.io/apache/superset-kubernetes-operator/charts/superset-operator:<version>`.
 - **Dependency policy:** Go modules, GitHub Actions, and container base images are kept current via [Renovate](https://docs.renovatebot.com/) with pinned versions and a 7-day minimum age before a *version* update is proposed. This soak does **not** apply to *digest* updates: when a floating tag is re-pushed to a new digest for the same version (a base-OS rebuild, or a patch published under a rolling tag), Renovate proposes the digest bump immediately, because `minimumReleaseAge` gates ve [...]
 - **SBOM & provenance:** Each published image carries a per-platform Software Bill of Materials (SPDX) and SLSA build provenance, attached as in-toto attestations by BuildKit at build time. Inspect them with `docker buildx imagetools inspect ghcr.io/apache/superset-kubernetes-operator:<tag> --format '{{ json .SBOM }}'` (or `.Provenance`), or with `cosign download attestation`.
 - **Vulnerability scanning:** CI runs [`govulncheck`](https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck) against the Go source (reachable CVEs in dependencies and the standard library) and [Trivy](https://github.com/aquasecurity/trivy) against the built image (OS and shipped-binary CVEs), with results uploaded to GitHub code scanning. [CodeQL](https://codeql.github.com/) provides static analysis of the Go code.
diff --git a/scripts/release-source.sh b/scripts/release-source.sh
index 5c9ed62..af485fb 100755
--- a/scripts/release-source.sh
+++ b/scripts/release-source.sh
@@ -281,13 +281,27 @@ case "$MODE" in
     : "${OUT_DIR:=dist/${VERSION}}"
     mkdir -p "$OUT_DIR"
 
-    # Locate the RC tarball and derive the final filename from the version arg.
-    # The glob matches both the current naming (`-rc<n>.tar.gz`) and the legacy
-    # `-rc<n>-source.tar.gz` used before the `-source` suffix was dropped, so a
-    # final can still be promoted from a pre-existing RC artifact.
-    RC_TARBALL=$(find "$RC_DIR" -maxdepth 1 -type f \
-                  -name "${PROJECT}-${VERSION}-rc*.tar.gz" | head -1)
-    [[ -n "$RC_TARBALL" ]] || die "no RC tarball found in ${RC_DIR}"
+    # Locate the tarball of the *voted* RC (the RC tag on HEAD) and derive the
+    # final filename from the version arg. The names match both the current
+    # naming (`-rc<n>.tar.gz`) and the legacy `-rc<n>-source.tar.gz` used
+    # before the `-source` suffix was dropped, so a final can still be
+    # promoted from a pre-existing RC artifact. Only rc${RC} is eligible:
+    # promoting any other tarball staged in --rc-dir (e.g. a failed earlier
+    # RC) would publish bytes the PMC never voted on, and ambiguity is a hard
+    # error rather than an arbitrary pick.
+    [[ "$RC" =~ ^[1-9][0-9]*$ ]] \
+      || die "cannot determine the voted RC number for finalize (got: '${RC}')"
+    RC_TARBALL=""
+    while IFS= read -r candidate; do
+      if [[ -n "$RC_TARBALL" ]]; then
+        die "multiple rc${RC} tarballs found in ${RC_DIR} ($(basename "$RC_TARBALL"), $(basename "$candidate"), ...); remove all but the voted artifact"
+      fi
+      RC_TARBALL="$candidate"
+    done < <(find "$RC_DIR" -maxdepth 1 -type f \
+              \( -name "${PROJECT}-${VERSION}-rc${RC}.tar.gz" \
+                 -o -name "${PROJECT}-${VERSION}-rc${RC}-source.tar.gz" \) \
+              | sort)
+    [[ -n "$RC_TARBALL" ]] || die "no rc${RC} tarball found in ${RC_DIR}"
     RC_BASE=$(basename "$RC_TARBALL")
     [[ -f "${RC_TARBALL}.asc" ]]    || die "missing detached signature: ${RC_BASE}.asc"
     [[ -f "${RC_TARBALL}.sha512" ]] || die "missing checksum file: ${RC_BASE}.sha512"