From 0454c8cb3e8a1f4f2bf6bce7c09e182aca9c69f2 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Mon, 6 Jul 2026 10:16:45 +0000 Subject: [PATCH 1/3] fix(release): sign and attest GitHub release assets directly OpenSSF Scorecard's Signed-Releases check only inspects GitHub release assets (matching *.sigstore.json / *.intoto.jsonl suffixes), not OCI registry signatures. The image and chart were already cosign-signed and SLSA-attested in ghcr.io, but Scorecard still flagged every release as unsigned/no-provenance because install.yaml and sbom.spdx.json carried neither. Sign each asset with cosign sign-blob and attest SLSA build provenance directly, uploading both alongside the original asset. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/release.yml | 47 ++++++++++++++++++++++++++++++++--- docs/ci-overview.md | 16 +++++++++++- 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f3e2d7da..60aaf1e1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -171,6 +171,23 @@ jobs: sbom-path: sbom.spdx.json push-to-registry: true + # The image itself is already signed + attested above, but OpenSSF + # Scorecard's Signed-Releases check only looks at the GitHub *release + # assets*, not the OCI registry. Sign and attest the SBOM asset directly + # so the release itself carries a signature (*.sigstore.json) and SLSA + # provenance (*.intoto.jsonl) next to it. + - name: Sign the SBOM asset (cosign keyless) + run: cosign sign-blob --bundle sbom.spdx.json.sigstore.json --yes sbom.spdx.json + + - name: Attest SLSA provenance for the SBOM asset + id: attest-sbom-asset + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: sbom.spdx.json + + - name: Rename provenance bundle to the Scorecard-recognized suffix + run: cp "${{ steps.attest-sbom-asset.outputs.bundle-path }}" sbom.spdx.json.intoto.jsonl + - name: Update release info uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 with: @@ -181,7 +198,10 @@ jobs: # it to published after all assets are attached. draft: true append_body: true - files: sbom.spdx.json + files: | + sbom.spdx.json + sbom.spdx.json.sigstore.json + sbom.spdx.json.intoto.jsonl body: | ## Installation ### Quick Install (Single YAML) @@ -226,7 +246,8 @@ jobs: permissions: contents: write # upload install.yaml release asset packages: write - id-token: write # cosign keyless signing + id-token: write # cosign keyless signing + attestation OIDC + attestations: write container: image: ${{ needs.ci.outputs.ci-image }} credentials: @@ -277,6 +298,23 @@ jobs: - name: Sign the Helm chart (cosign keyless) run: cosign sign --yes "${CHART_REGISTRY}/gitops-reverser@${{ steps.chart.outputs.digest }}" + # The chart is already signed above via its OCI digest, but OpenSSF + # Scorecard's Signed-Releases check only looks at the GitHub *release + # assets*, not the OCI registry. Sign and attest install.yaml directly so + # the release carries a signature (*.sigstore.json) and SLSA provenance + # (*.intoto.jsonl) next to it. + - name: Sign install.yaml (cosign keyless) + run: cosign sign-blob --bundle install.yaml.sigstore.json --yes dist/install.yaml + + - name: Attest SLSA provenance for install.yaml + id: attest-install-yaml + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: dist/install.yaml + + - name: Rename provenance bundle to the Scorecard-recognized suffix + run: cp "${{ steps.attest-install-yaml.outputs.bundle-path }}" install.yaml.intoto.jsonl + - name: Upload install.yaml as release asset uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 with: @@ -285,7 +323,10 @@ jobs: # every asset is in place (immutable releases reject post-publish # uploads). draft: true - files: dist/install.yaml + files: | + dist/install.yaml + install.yaml.sigstore.json + install.yaml.intoto.jsonl env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/docs/ci-overview.md b/docs/ci-overview.md index c62e8661..bb212218 100644 --- a/docs/ci-overview.md +++ b/docs/ci-overview.md @@ -84,7 +84,7 @@ step merges those exact digests into a multi-arch manifest tagged with the semve | --- | --- | --- | | Multi-arch image (`linux/amd64`, `linux/arm64`) | `ghcr.io/configbutler/gitops-reverser` | cosign keyless signature, SLSA build provenance attestation, SPDX SBOM attestation | | Helm chart | `oci://ghcr.io/configbutler/charts/gitops-reverser` | cosign keyless signature | -| `install.yaml` + `sbom.spdx.json` | GitHub release assets | part of the signed release | +| `install.yaml`, `sbom.spdx.json` | GitHub release assets | each signed directly (`.sigstore.json`) and SLSA-attested (`.intoto.jsonl`), also uploaded as release assets | Verify an image (also embedded in every release's notes): @@ -98,6 +98,20 @@ gh attestation verify oci://ghcr.io/configbutler/gitops-reverser: \ --repo ConfigButler/gitops-reverser ``` +Verify a release asset (e.g. `install.yaml`) from its downloaded `.sigstore.json` bundle: + +```bash +cosign verify-blob \ + --bundle install.yaml.sigstore.json \ + --certificate-identity-regexp '^https://github.com/ConfigButler/gitops-reverser/\.github/workflows/release\.yml@refs/heads/main$' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + install.yaml +``` + +These per-asset signatures/attestations exist specifically so release assets carry their +own verifiable integrity, independent of the OCI registry — the surface OpenSSF Scorecard's +`Signed-Releases` check actually inspects. + Signing is *keyless* (Sigstore): there is no private key to store or leak. The signature certifies "built by the `release.yml` workflow on `main` of this repository", issued via GitHub's OIDC identity and logged in the public Rekor transparency log. From 689f34b9246d5709da1919fa67860d4bd7441813 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Mon, 6 Jul 2026 10:22:14 +0000 Subject: [PATCH 2/3] build: bake cosign into the ci-stage container image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit publish-helm already runs inside the ci-image container and was installing cosign at runtime via sigstore/cosign-installer just to sign the chart and (per the prior commit) install.yaml. Bake cosign into the ci stage of .devcontainer/Dockerfile instead — same checksum-verified pattern as the other CLI tools there — so that job drops its installer step, and cosign verify/verify-blob become available locally without a separate install. publish-manifest keeps its own cosign-installer: it runs on a bare ubuntu-latest runner, not the ci-image. Co-Authored-By: Claude Sonnet 5 --- .devcontainer/Dockerfile | 20 +++++++++++++++++++- .github/workflows/ci.yml | 1 + .github/workflows/release.yml | 5 ++--- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 188fbd72..9e788b5c 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -58,6 +58,7 @@ RUN apt-get update \ # ACTIONLINT_VERSION -> https://github.com/rhysd/actionlint/releases # HADOLINT_VERSION -> https://github.com/hadolint/hadolint/releases # VALKEY_VERSION -> https://github.com/valkey-io/valkey/releases +# COSIGN_VERSION -> https://github.com/sigstore/cosign/releases ENV PATH="/go/bin:/usr/local/go/bin:${PATH}" \ KUBECTL_VERSION=v1.36.2 \ @@ -71,7 +72,8 @@ ENV PATH="/go/bin:/usr/local/go/bin:${PATH}" \ TASK_VERSION=v3.51.1 \ ACTIONLINT_VERSION=1.7.12 \ HADOLINT_VERSION=2.14.0 \ - VALKEY_VERSION=9.1.0 + VALKEY_VERSION=9.1.0 \ + COSIGN_VERSION=v3.1.1 # Fail early on unsupported architectures instead of producing a partial image. RUN test "$(dpkg --print-architecture)" = "amd64" \ @@ -158,6 +160,22 @@ RUN curl -fsSL "https://download.valkey.io/releases/valkey-${VALKEY_VERSION}-jam | tar -xzO "valkey-${VALKEY_VERSION}-jammy-x86_64/bin/valkey-cli" > /usr/local/bin/valkey-cli \ && chmod +x /usr/local/bin/valkey-cli +# Install cosign (Sigstore keyless signing/verification). Lives in the ci stage, +# not dev-only: release.yml's publish-helm job runs *inside* this same image +# (see `container:` in .github/workflows/release.yml) and signs install.yaml +# with cosign — baking it in here means that job no longer needs its own +# sigstore/cosign-installer step. Also lets you run `cosign verify`/`verify-blob` +# locally against what release.yml publishes (see docs/ci-overview.md). +RUN asset="cosign-linux-amd64" \ + && base="https://github.com/sigstore/cosign/releases/download/${COSIGN_VERSION}" \ + && tmpdir="$(mktemp -d)" \ + && curl -fsSL "${base}/${asset}" -o "${tmpdir}/${asset}" \ + && curl -fsSL "${base}/cosign_checksums.txt" -o "${tmpdir}/checksums.txt" \ + && cd "${tmpdir}" \ + && grep " ${asset}$" checksums.txt | sha256sum -c - \ + && install -m 0755 "${asset}" /usr/local/bin/cosign \ + && rm -rf "${tmpdir}" + # Set working directory WORKDIR /workspaces diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1445963b..4179417a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,6 +107,7 @@ jobs: controller-gen --version k3d version docker --version + cosign version echo '✅ All CI container tools verified' " diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 60aaf1e1..f132fadd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -283,9 +283,8 @@ jobs: fi echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT" - - name: Install cosign - uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 - + # cosign ships baked into the ci-image this job runs in (see + # .devcontainer/Dockerfile) — no installer step needed here. - name: Log in to registry for cosign # `helm registry login` writes helm's own registry config; cosign reads # ~/.docker/config.json, so it needs its own login or the signature From 85dbec00b7743469855ffbf7b0e957302c7e45b1 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Mon, 6 Jul 2026 10:26:58 +0000 Subject: [PATCH 3/3] fix(release): pin cosign-installer to the same version as the ci-image sigstore/cosign-installer defaults to cosign v3.0.6 when cosign-release isn't set, which silently diverged from the v3.1.1 baked into the ci-image that publish-helm signs with. Pin it explicitly so both signing jobs in the same release run the same cosign binary. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/release.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f132fadd..eb8c208b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -142,6 +142,12 @@ jobs: - name: Install cosign uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + with: + # Keep in sync with COSIGN_VERSION in .devcontainer/Dockerfile — this + # is the only other job that signs release artifacts (publish-helm), + # and it gets cosign from that image. Without this input the action + # defaults to its own (older) pinned cosign release. + cosign-release: v3.1.1 - name: Sign the multi-arch image (cosign keyless) # One signature on the digest covers every tag pointing at it.