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"