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()