diff --git a/.github/workflows/publish-images.yml b/.github/workflows/publish-images.yml index 0253f53..5027db7 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: | @@ -269,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 @@ -293,12 +304,88 @@ 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 }}" + variant="${{ matrix.image.variant }}" + prefix="" + if [ -n "$variant" ] && [ "$variant" != "base" ]; then + prefix="${variant}-" + fi docker push "$ref:$tag" docker push "$ref:$latest_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) + 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 20f9860..f70f072 100644 --- a/tests/test_publish_images_workflow.py +++ b/tests/test_publish_images_workflow.py @@ -104,6 +104,65 @@ 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"]) + 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"]} + 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)