From dff78cda651741abfb319efccb5ef80d633d2fbf Mon Sep 17 00:00:00 2001 From: RonaldHensbergen Date: Wed, 26 Aug 2026 19:30:32 +0200 Subject: [PATCH 1/2] feat(images): sign, SBOM, and attest provenance for Docker Hub publishes Extends the publish-dockerhub job in publish-images.yml to match the ghcr.io publish job's supply-chain guarantees: - cosign keyless-sign the pushed digest - generate and attest a CycloneDX SBOM - write and attest an SLSA provenance predicate - self-verify the signature after signing Adds id-token: write permission and the cosign-installer step needed for keyless signing. Updates docs/image-signing.md to document Docker Hub alongside GHCR (naming/tagging differences, shared trust identity), and adds workflow-structure test coverage in tests/test_publish_images_workflow.py mirroring the existing ghcr.io assertions. Resolves #275 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/publish-images.yml | 61 +++++++++++++++++++++++++++ docs/image-signing.md | 27 ++++++++++-- tests/test_publish_images_workflow.py | 41 ++++++++++++++++++ 3 files changed, 126 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish-images.yml b/.github/workflows/publish-images.yml index 0253f534..8f39b2d0 100644 --- a/.github/workflows/publish-images.yml +++ b/.github/workflows/publish-images.yml @@ -215,6 +215,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + id-token: write strategy: fail-fast: false matrix: @@ -224,6 +225,8 @@ jobs: - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4 + - uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + - name: Determine version id: version run: | @@ -293,12 +296,70 @@ jobs: ignore-unfixed: "true" - name: Push image + id: push run: | ref="docker.io/ronaldsoeverein/${{ matrix.image.name }}" tag="${{ steps.version.outputs.tag }}" latest_tag="${{ steps.version.outputs.latest_tag }}" docker push "$ref:$tag" docker push "$ref:$latest_tag" + digest="$(docker inspect --format='{{index .RepoDigests 0}}' "$ref:$tag")" + echo "digest=$digest" >> "$GITHUB_OUTPUT" + + - name: Sign image (keyless) + run: cosign sign --yes "${{ steps.push.outputs.digest }}" + + - name: Generate SBOM + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + # Pin the Trivy CLI version explicitly (see the equivalent ghcr.io + # "Generate SBOM" step above) so it is a visible, Renovate-trackable + # dependency rather than a value hidden inside the action default. + version: v0.74.0 + image-ref: ${{ steps.push.outputs.digest }} + format: cyclonedx + output: sbom.json + + - name: Attest SBOM + run: cosign attest --yes --predicate sbom.json --type cyclonedx "${{ steps.push.outputs.digest }}" + + - name: Write provenance predicate + run: | + cat > provenance.json <` (e.g. `cds-superset`, `cds-dagster`); owner is lowercased, GHCR rejects uppercase. - Tags: `sha-<12-char-commit-sha>` and `latest`. For images with multiple build variants (e.g. Dagster's `base`/`hardened`), the non-default variant's tags are prefixed with the variant name (e.g. `hardened-sha-`, `hardened-latest`); the image name/repository stays the same across variants. Tags are mutable pointers for humans; they are **not** what gets signed or verified. +### Docker Hub + +- Registry: `docker.io` (`registry-1.docker.io`) +- Name: `docker.io/ronaldsoeverein/` (e.g. `dagster`, `superset`, `dbt`; no `cds-` prefix). +- Tags: a base-version tag derived per image (`dagster==`/`apache/superset:`/`dbt-core==` version) and `latest`, both optionally prefixed with the build variant (e.g. `hardened-1.8.0`, `hardened-latest`). As with GHCR, tags are mutable; verification is always by digest. + ## Verifying an image -Always verify by digest, not by tag: +Always verify by digest, not by tag. The same certificate identity/OIDC +issuer verifies both registries, since both jobs run from the same +`publish-images.yml` workflow: ```bash cosign verify \ --certificate-identity-regexp "^https://github.com/RonaldHensbergen/composable-data-stack/.github/workflows/publish-images.yml@refs/heads/main$" \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ ghcr.io/ronaldhensbergen/cds-superset@sha256: + +cosign verify \ + --certificate-identity-regexp "^https://github.com/RonaldHensbergen/composable-data-stack/.github/workflows/publish-images.yml@refs/heads/main$" \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + docker.io/ronaldsoeverein/superset@sha256: ``` Attestations (SBOM, provenance) verify the same way with `cosign verify-attestation --type cyclonedx` / `--type slsaprovenance` in place of `cosign verify`. @@ -26,7 +47,7 @@ Attestations (SBOM, provenance) verify the same way with `cosign verify-attestat Keyless signing via GitHub OIDC, no private key or long-lived secret is stored anywhere. The identity being trusted is: - **OIDC issuer:** `https://token.actions.githubusercontent.com` -- **Certificate identity:** the `publish-images.yml` workflow running on `refs/heads/main` in this repo, specifically; a signature minted by any other workflow, branch, or fork will not match. +- **Certificate identity:** the `publish-images.yml` workflow running on `refs/heads/main` in this repo, specifically; a signature minted by any other workflow, branch, or fork will not match. This holds for both the GHCR and Docker Hub publish jobs, since both run from this same workflow file. ## Provenance scope diff --git a/tests/test_publish_images_workflow.py b/tests/test_publish_images_workflow.py index 20f98602..fcf79005 100644 --- a/tests/test_publish_images_workflow.py +++ b/tests/test_publish_images_workflow.py @@ -104,6 +104,47 @@ def test_vuln_gate_present_in_dockerhub_job(self) -> None: names.index("Push image"), ) + def test_dockerhub_job_has_id_token_permission_for_keyless_signing(self) -> None: + perms = self.jobs["publish-dockerhub"]["permissions"] + self.assertEqual(perms.get("id-token"), "write") + + def test_dockerhub_job_signs_and_attests_by_digest(self) -> None: + job = self.jobs["publish-dockerhub"] + push_step = next(s for s in job["steps"] if s.get("id") == "push") + self.assertIn("$(docker inspect", push_step["run"]) + self.assertIn('echo "digest=$digest" >> "$GITHUB_OUTPUT"', push_step["run"]) + + for step_name in ("Sign image (keyless)", "Attest SBOM", "Attest provenance"): + with self.subTest(step=step_name): + step = next(s for s in job["steps"] if s.get("name") == step_name) + self.assertIn("steps.push.outputs.digest", step["run"]) + + def test_dockerhub_job_sbom_and_provenance_are_both_attested(self) -> None: + job = self.jobs["publish-dockerhub"] + names = {s.get("name") for s in job["steps"]} + self.assertIn("Attest SBOM", names) + self.assertIn("Attest provenance", names) + sbom_attest = next(s for s in job["steps"] if s.get("name") == "Attest SBOM") + self.assertIn("--type cyclonedx", sbom_attest["run"]) + provenance_attest = next( + s for s in job["steps"] if s.get("name") == "Attest provenance" + ) + self.assertIn("--type slsaprovenance", provenance_attest["run"]) + + def test_dockerhub_job_uses_cosign_installer(self) -> None: + job = self.jobs["publish-dockerhub"] + uses = [str(s.get("uses", "")) for s in job["steps"]] + self.assertTrue(any(u.startswith("sigstore/cosign-installer@") for u in uses)) + + def test_dockerhub_gate_runs_before_sign_and_push(self) -> None: + job = self.jobs["publish-dockerhub"] + names = [s.get("name") for s in job["steps"]] + gate_idx = names.index("Gate on HIGH/CRITICAL vulnerabilities before push") + push_idx = names.index("Push image") + sign_idx = names.index("Sign image (keyless)") + self.assertLess(gate_idx, push_idx) + self.assertLess(push_idx, sign_idx) + def test_sbom_and_provenance_are_both_attested(self) -> None: names = {s.get("name") for s in self.publish_steps} self.assertIn("Attest SBOM", names) From 9073cb8776d97b9f57f9786e563c07cc906f9d19 Mon Sep 17 00:00:00 2001 From: Ronald Hensbergen Date: Thu, 27 Aug 2026 10:12:15 +0000 Subject: [PATCH 2/2] fix(ci): verify Docker Hub attestations Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/publish-images.yml | 34 +++++++++++++++++++++++---- tests/test_publish_images_workflow.py | 18 ++++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish-images.yml b/.github/workflows/publish-images.yml index 8f39b2d0..5027db7e 100644 --- a/.github/workflows/publish-images.yml +++ b/.github/workflows/publish-images.yml @@ -272,11 +272,19 @@ jobs: ref="docker.io/ronaldsoeverein/${{ matrix.image.name }}" tag="${{ steps.version.outputs.tag }}" latest_tag="${{ steps.version.outputs.latest_tag }}" - # The :scan tag is local-only and never pushed; it exists so the - # vulnerability gate below can scan the exact image about to be - # published before anything reaches the registry. + # The :scan and :sha tags are local-only until the push step. The + # :scan tag exists so the vulnerability gate below can scan the + # exact image about to be published before anything reaches the + # registry; the :sha tag is immutable once published and is used + # to obtain the digest for signing and attestations. + variant="${{ matrix.image.variant }}" + prefix="" + if [ -n "$variant" ] && [ "$variant" != "base" ]; then + prefix="${variant}-" + fi docker build -f "$dockerfile" \ -t "$ref:$tag" -t "$ref:$latest_tag" \ + -t "$ref:${prefix}sha-${GITHUB_SHA::12}" \ -t "cds/${{ matrix.image.name }}${{ matrix.image.variant != '' && format('-{0}', matrix.image.variant) || '' }}:scan" "$context" - name: Gate on HIGH/CRITICAL vulnerabilities before push @@ -301,9 +309,15 @@ jobs: ref="docker.io/ronaldsoeverein/${{ matrix.image.name }}" tag="${{ steps.version.outputs.tag }}" latest_tag="${{ steps.version.outputs.latest_tag }}" + variant="${{ matrix.image.variant }}" + prefix="" + if [ -n "$variant" ] && [ "$variant" != "base" ]; then + prefix="${variant}-" + fi docker push "$ref:$tag" docker push "$ref:$latest_tag" - digest="$(docker inspect --format='{{index .RepoDigests 0}}' "$ref:$tag")" + docker push "$ref:${prefix}sha-${GITHUB_SHA::12}" + digest="$(docker inspect --format='{{index .RepoDigests 0}}' "$ref:${prefix}sha-${GITHUB_SHA::12}")" echo "digest=$digest" >> "$GITHUB_OUTPUT" - name: Sign image (keyless) @@ -360,6 +374,18 @@ jobs: '^https://github\.com/${{ github.repository }}/\.github/workflows/publish-images\.yml@${{ github.ref }}$' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ "${{ steps.push.outputs.digest }}" + cosign verify-attestation \ + --type cyclonedx \ + --certificate-identity-regexp \ + '^https://github\.com/${{ github.repository }}/\.github/workflows/publish-images\.yml@${{ github.ref }}$' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + "${{ steps.push.outputs.digest }}" + cosign verify-attestation \ + --type slsaprovenance \ + --certificate-identity-regexp \ + '^https://github\.com/${{ github.repository }}/\.github/workflows/publish-images\.yml@${{ github.ref }}$' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + "${{ steps.push.outputs.digest }}" - name: Update Docker Hub overview if: matrix.image.variant == '' || matrix.image.variant == 'base' diff --git a/tests/test_publish_images_workflow.py b/tests/test_publish_images_workflow.py index fcf79005..f70f0723 100644 --- a/tests/test_publish_images_workflow.py +++ b/tests/test_publish_images_workflow.py @@ -113,12 +113,30 @@ def test_dockerhub_job_signs_and_attests_by_digest(self) -> None: push_step = next(s for s in job["steps"] if s.get("id") == "push") self.assertIn("$(docker inspect", push_step["run"]) self.assertIn('echo "digest=$digest" >> "$GITHUB_OUTPUT"', push_step["run"]) + self.assertIn('"$ref:${prefix}sha-${GITHUB_SHA::12}"', push_step["run"]) for step_name in ("Sign image (keyless)", "Attest SBOM", "Attest provenance"): with self.subTest(step=step_name): step = next(s for s in job["steps"] if s.get("name") == step_name) self.assertIn("steps.push.outputs.digest", step["run"]) + def test_dockerhub_job_verifies_signature_and_attestations(self) -> None: + job = self.jobs["publish-dockerhub"] + verify_step = next( + s for s in job["steps"] if s.get("name") == "Verify signed image (self-check)" + ) + verify_run = verify_step["run"] + self.assertIn("cosign verify \\", verify_run) + self.assertIn("cosign verify-attestation \\", verify_run) + self.assertIn("--type cyclonedx", verify_run) + self.assertIn("--type slsaprovenance", verify_run) + self.assertIn("steps.push.outputs.digest", verify_run) + self.assertIn( + "'^https://github\\.com/${{ github.repository }}/\\.github/workflows/" + "publish-images\\.yml@${{ github.ref }}$'", + verify_run, + ) + def test_dockerhub_job_sbom_and_provenance_are_both_attested(self) -> None: job = self.jobs["publish-dockerhub"] names = {s.get("name") for s in job["steps"]}