From f60c43f12b29bb7099c0c30324fa3f69ce86bb09 Mon Sep 17 00:00:00 2001 From: Alex Pyrgiotis Date: Tue, 11 Feb 2025 19:15:49 +0100 Subject: [PATCH] Publish and attest multi-architecture container images A new `dangerzone-image attest-provenance` script is now available, making it possible to verify the attestations of an image published on the github container registry. Container images are now build nightly and uploaded to the container registry. --- .github/workflows/release-container-image.yml | 168 ++++++++++++++++++ dangerzone/updater/attestations.py | 92 ++++++++++ dangerzone/updater/cli.py | 49 +++++ 3 files changed, 309 insertions(+) create mode 100644 .github/workflows/release-container-image.yml create mode 100644 dangerzone/updater/attestations.py diff --git a/.github/workflows/release-container-image.yml b/.github/workflows/release-container-image.yml new file mode 100644 index 0000000..cfc4081 --- /dev/null +++ b/.github/workflows/release-container-image.yml @@ -0,0 +1,168 @@ +name: Release multi-arch container image + +on: + workflow_dispatch: + push: + branches: + - main + - "test/**" + schedule: + - cron: "0 0 * * *" # Run every day at 00:00 UTC. + +env: + REGISTRY: ghcr.io/${{ github.repository_owner }} + REGISTRY_USER: ${{ github.actor }} + REGISTRY_PASSWORD: ${{ github.token }} + IMAGE_NAME: dangerzone/dangerzone + +jobs: + build: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + platform: + - linux/amd64 + - linux/arm64 + steps: + - uses: actions/checkout@v4 + + - name: Get current date + id: date + run: echo "date=$(date +'%Y%m%d')" >> $GITHUB_OUTPUT + + - name: Prepare + run: | + platform=${{ matrix.platform }} + echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push by digest + id: build + uses: docker/build-push-action@v6 + with: + context: ./dangerzone/ + file: Dockerfile + build-args: | + DEBIAN_ARCHIVE_DATE=${{ steps.date.outputs.date }} + ## Remove potentially incorrect Docker provenance. + #provenance: false + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} + outputs: type=image,"name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}",push-by-digest=true,name-canonical=true,push=true + + - name: Export digest + run: | + mkdir -p ${{ runner.temp }}/digests + digest="${{ steps.build.outputs.digest }}" + touch "${{ runner.temp }}/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-${{ env.PLATFORM_PAIR }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + runs-on: ubuntu-latest + needs: + - build + outputs: + digest: ${{ steps.image.outputs.digest }} + image: ${{ steps.image.outputs.image }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Compute image tag + id: tag + run: | + DATE=$(date +'%Y%m%d') + TAG=$(git describe --long --first-parent | tail -c +2) + echo "tag=${DATE}-${TAG}" >> $GITHUB_OUTPUT + + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: ${{ runner.temp }}/digests + pattern: digests-* + merge-multiple: true + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + #- name: Docker meta + # id: meta + # uses: docker/metadata-action@v5 + # with: + # images: | + # ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + # tags: | + # type=ref,event=branch + # type=ref,event=pr + # type=semver,pattern={{version}} + # type=semver,pattern={{major}}.{{minor}} + + - name: Create manifest list and push + working-directory: ${{ runner.temp }}/digests + run: | + IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }} + DIGESTS=$(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *) + docker buildx imagetools create -t ${IMAGE} ${DIGESTS} + + - name: Inspect image + id: image + run: | + # NOTE: Set the image as an output because the `env` context is not + # available to the inputs of a reusable workflow call. + image_name="${REGISTRY}/${IMAGE_NAME}" + echo "image=$image_name" >> "$GITHUB_OUTPUT" + docker buildx imagetools inspect ${image_name}:${{ steps.tag.outputs.tag }} + digest=$(docker buildx imagetools inspect ${image_name}:${{ steps.tag.outputs.tag }} --format "{{json .Manifest}}" | jq -r '.digest') + echo "digest=$digest" >> "$GITHUB_OUTPUT" + + # This step calls the container workflow to generate provenance and push it to + # the container registry. + provenance: + needs: + - merge + permissions: + actions: read # for detecting the Github Actions environment. + id-token: write # for creating OIDC tokens for signing. + packages: write # for uploading attestations. + uses: slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@v2.0.0 + with: + digest: ${{ needs.merge.outputs.digest }} + image: ${{ needs.merge.outputs.image }} + registry-username: ${{ github.actor }} + secrets: + registry-password: ${{ secrets.GITHUB_TOKEN }} diff --git a/dangerzone/updater/attestations.py b/dangerzone/updater/attestations.py new file mode 100644 index 0000000..90bf152 --- /dev/null +++ b/dangerzone/updater/attestations.py @@ -0,0 +1,92 @@ +import subprocess +from tempfile import NamedTemporaryFile + +from . import cosign + +# NOTE: You can grab the SLSA attestation for an image/tag pair with the following +# commands: +# +# IMAGE=ghcr.io/apyrgio/dangerzone/dangerzone +# TAG=20250129-0.8.0-149-gbf2f5ac +# DIGEST=$(crane digest ${IMAGE?}:${TAG?}) +# ATT_MANIFEST=${IMAGE?}:${DIGEST/:/-}.att +# ATT_BLOB=${IMAGE?}@$(crane manifest ${ATT_MANIFEST?} | jq -r '.layers[0].digest') +# crane blob ${ATT_BLOB?} | jq -r '.payload' | base64 -d | jq +CUE_POLICY = r""" +// The predicateType field must match this string +predicateType: "https://slsa.dev/provenance/v0.2" + +predicate: {{ + // This condition verifies that the builder is the builder we + // expect and trust. The following condition can be used + // unmodified. It verifies that the builder is the container + // workflow. + builder: {{ + id: =~"^https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@refs/tags/v[0-9]+.[0-9]+.[0-9]+$" + }} + invocation: {{ + configSource: {{ + // This condition verifies the entrypoint of the workflow. + // Replace with the relative path to your workflow in your + // repository. + entryPoint: "{workflow}" + + // This condition verifies that the image was generated from + // the source repository we expect. Replace this with your + // repository. + uri: =~"^git\\+https://github.com/{repo}@refs/heads/{branch}" + // Add a condition to check for a specific commit hash + digest: {{ + sha1: "{commit}" + }} + }} + }} +}} +""" + + +def generate_cue_policy(repo, workflow, commit, branch): + return CUE_POLICY.format(repo=repo, workflow=workflow, commit=commit, branch=branch) + + +def verify( + image_name: str, + branch: str, + commit: str, + repository: str, + workflow: str, +) -> bool: + """ + Look up the image attestation to see if the image has been built + on Github runners, and from a given repository. + """ + cosign.ensure_installed() + policy = generate_cue_policy(repository, workflow, commit, branch) + + # Put the value in files and verify with cosign + with ( + NamedTemporaryFile(mode="w", suffix=".cue") as policy_f, + ): + policy_f.write(policy) + policy_f.flush() + + # Call cosign with the temporary file paths + cmd = [ + "cosign", + "verify-attestation", + "--type", + "slsaprovenance", + "--policy", + policy_f.name, + "--certificate-oidc-issuer", + "https://token.actions.githubusercontent.com", + "--certificate-identity-regexp", + "^https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@refs/tags/v[0-9]+.[0-9]+.[0-9]+$", + image_name, + ] + + result = subprocess.run(cmd, capture_output=True) + if result.returncode != 0: + error = result.stderr.decode() + raise Exception(f"Attestation cannot be verified. {error}") + return True diff --git a/dangerzone/updater/cli.py b/dangerzone/updater/cli.py index 9eab01e..42fe58c 100644 --- a/dangerzone/updater/cli.py +++ b/dangerzone/updater/cli.py @@ -101,5 +101,54 @@ def get_manifest(image: str) -> None: click.echo(registry.get_manifest(image).content) +@main.command() +@click.argument("image_name") +# XXX: Do we really want to check against this? +@click.option( + "--branch", + default=DEFAULT_BRANCH, + help="The Git branch that the image was built from", +) +@click.option( + "--commit", + required=True, + help="The Git commit the image was built from", +) +@click.option( + "--repository", + default=DEFAULT_REPOSITORY, + help="The github repository to check the attestation for", +) +@click.option( + "--workflow", + default=".github/workflows/release-container-image.yml", + help="The path of the GitHub actions workflow this image was created from", +) +def attest_provenance( + image_name: str, + branch: str, + commit: str, + repository: str, + workflow: str, +) -> None: + """ + Look up the image attestation to see if the image has been built + on Github runners, and from a given repository. + """ + # TODO: Parse image and make sure it has a tag. Might even check for a digest. + # parsed = registry.parse_image_location(image) + + verified = attestations.verify(image_name, branch, commit, repository, workflow) + if verified: + click.echo( + f"🎉 Successfully verified image '{image_name}' and its associated claims:" + ) + click.echo(f"- ✅ SLSA Level 3 provenance") + click.echo(f"- ✅ GitHub repo: {repository}") + click.echo(f"- ✅ GitHub actions workflow: {workflow}") + click.echo(f"- ✅ Git branch: {branch}") + click.echo(f"- ✅ Git commit: {commit}") + + if __name__ == "__main__": main()