Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 90 additions & 3 deletions .github/workflows/publish-images.yml
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
strategy:
fail-fast: false
matrix:
Expand All @@ -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: |
Expand Down Expand Up @@ -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
Expand All @@ -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 <<EOF
{
"builder": { "id": "https://github.com/actions/runner" },
"buildType": "https://github.com/${{ github.repository }}/publish-images@v1",
"invocation": {
"configSource": {
"uri": "git+https://github.com/${{ github.repository }}@${{ github.ref }}",
"digest": { "sha1": "${{ github.sha }}" },
"entryPoint": ".github/workflows/publish-images.yml"
}
},
"metadata": {
"buildInvocationId": "${{ github.run_id }}-${{ github.run_attempt }}",
"completeness": { "parameters": true, "environment": false, "materials": false },
"reproducible": false
},
"materials": [
{
"uri": "git+https://github.com/${{ github.repository }}@${{ github.ref }}",
"digest": { "sha1": "${{ github.sha }}" }
}
]
}
EOF

- name: Attest provenance
run: cosign attest --yes --predicate provenance.json --type slsaprovenance "${{ steps.push.outputs.digest }}"

- name: Verify signed image (self-check)
run: |
cosign verify \
--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 }}"
Comment thread
RonaldHensbergen marked this conversation as resolved.
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'
Expand Down
27 changes: 24 additions & 3 deletions docs/image-signing.md
Original file line number Diff line number Diff line change
@@ -1,22 +1,43 @@
# Signed image publication

Runtime images under `images/**` are published to GHCR and signed on every merge to `main` that touches them, by `.github/workflows/publish-images.yml`.
Runtime images under `images/**` are published to GHCR and Docker Hub and
signed on every merge to `main` that touches them, by
`.github/workflows/publish-images.yml` (`publish` job for GHCR,
`publish-dockerhub` job for Docker Hub). Both jobs use the same keyless
cosign signing, CycloneDX SBOM attestation, and SLSA provenance attestation
steps; only the registry, image naming/tagging scheme, and credentials
differ (see below).

## Registry and naming

### GHCR

- Registry: `ghcr.io`
- Name: `ghcr.io/ronaldhensbergen/cds-<image-name>` (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-<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/<image-name>` (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:<digest>

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:<digest>
```

Attestations (SBOM, provenance) verify the same way with `cosign verify-attestation --type cyclonedx` / `--type slsaprovenance` in place of `cosign verify`.
Expand All @@ -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

Expand Down
59 changes: 59 additions & 0 deletions tests/test_publish_images_workflow.py
Comment thread
RonaldHensbergen marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down