From 93b95dbb59ac21a5f4263566e1ee895dd9a9f412 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexis=20M=C3=A9taireau?= Date: Wed, 27 Nov 2024 14:44:05 +0100 Subject: [PATCH 1/9] Build: Use Github runners to build and sign container images on new tags --- .github/workflows/release-container-image.yml | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 .github/workflows/release-container-image.yml diff --git a/.github/workflows/release-container-image.yml b/.github/workflows/release-container-image.yml new file mode 100644 index 0000000..be05626 --- /dev/null +++ b/.github/workflows/release-container-image.yml @@ -0,0 +1,56 @@ +# This action listens on new tags, generates a new container image +# sign it and upload it to the container registry. + +name: Release container image +on: + push: + tags: + - "container-image/**" + branches: + - "test/image-**" + workflow_dispatch: + +permissions: + id-token: write + packages: write + contents: read + attestations: write + +env: + REGISTRY: ghcr.io/${{ github.repository_owner }} + REGISTRY_USER: ${{ github.actor }} + REGISTRY_PASSWORD: ${{ github.token }} + IMAGE_NAME: dangerzone/dangerzone + +jobs: + build-container-image: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: USERNAME + password: ${{ github.token }} + + - name: Build and push the dangerzone image + id: build-image + run: | + sudo apt-get install -y python3-poetry + python3 ./install/common/build-image.py + echo ${{ github.token }} | podman login ghcr.io -u USERNAME --password-stdin + + # Load the image with the final name directly + gunzip -c share/container.tar.gz | podman load + FINAL_IMAGE_NAME="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" + podman tag dangerzone.rocks/dangerzone "$FINAL_IMAGE_NAME" + podman push "$FINAL_IMAGE_NAME" --digestfile=digest + echo "digest=$(cat digest)" >> "$GITHUB_OUTPUT" + + - name: Generate artifact attestation + uses: actions/attest-build-provenance@v1 + with: + subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + subject-digest: "${{ steps.build-image.outputs.digest }}" + push-to-registry: true From 1ec51c7b4cc9f1d6375c1a1fb66094a5f561960b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexis=20M=C3=A9taireau?= Date: Mon, 20 Jan 2025 14:25:26 +0100 Subject: [PATCH 2/9] Checkout with depth:0 otherwise git commands aren't functional --- .github/workflows/release-container-image.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/release-container-image.yml b/.github/workflows/release-container-image.yml index be05626..9947284 100644 --- a/.github/workflows/release-container-image.yml +++ b/.github/workflows/release-container-image.yml @@ -27,6 +27,9 @@ jobs: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Login to GitHub Container Registry uses: docker/login-action@v3 with: From 033e8fef884110e245e318c57989724b7e9755f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexis=20M=C3=A9taireau?= Date: Mon, 20 Jan 2025 14:46:51 +0100 Subject: [PATCH 3/9] Get the tag from git before retagging it --- .github/workflows/release-container-image.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-container-image.yml b/.github/workflows/release-container-image.yml index 9947284..7177e93 100644 --- a/.github/workflows/release-container-image.yml +++ b/.github/workflows/release-container-image.yml @@ -47,7 +47,8 @@ jobs: # Load the image with the final name directly gunzip -c share/container.tar.gz | podman load FINAL_IMAGE_NAME="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" - podman tag dangerzone.rocks/dangerzone "$FINAL_IMAGE_NAME" + TAG=$(git describe --long --first-parent | tail -c +2) + podman tag dangerzone.rocks/dangerzone:$TAG "$FINAL_IMAGE_NAME" podman push "$FINAL_IMAGE_NAME" --digestfile=digest echo "digest=$(cat digest)" >> "$GITHUB_OUTPUT" From 74f4fbbbdeb1dc5f2a4f09388a88c5b2eeecd5ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexis=20M=C3=A9taireau?= Date: Mon, 20 Jan 2025 15:16:13 +0100 Subject: [PATCH 4/9] Add the tag to the subject --- .github/workflows/release-container-image.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-container-image.yml b/.github/workflows/release-container-image.yml index 7177e93..752c27f 100644 --- a/.github/workflows/release-container-image.yml +++ b/.github/workflows/release-container-image.yml @@ -51,10 +51,11 @@ jobs: podman tag dangerzone.rocks/dangerzone:$TAG "$FINAL_IMAGE_NAME" podman push "$FINAL_IMAGE_NAME" --digestfile=digest echo "digest=$(cat digest)" >> "$GITHUB_OUTPUT" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" - name: Generate artifact attestation uses: actions/attest-build-provenance@v1 with: - subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.build-image.outputs.tag }} subject-digest: "${{ steps.build-image.outputs.digest }}" push-to-registry: true From 02fb6c07a4076ceab8749e50bc158e6c051f1fe7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexis=20M=C3=A9taireau?= Date: Mon, 20 Jan 2025 15:25:51 +0100 Subject: [PATCH 5/9] Remove the tag from the attestation, what we attest is the hash, so no need for it --- .github/workflows/release-container-image.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/release-container-image.yml b/.github/workflows/release-container-image.yml index 752c27f..7177e93 100644 --- a/.github/workflows/release-container-image.yml +++ b/.github/workflows/release-container-image.yml @@ -51,11 +51,10 @@ jobs: podman tag dangerzone.rocks/dangerzone:$TAG "$FINAL_IMAGE_NAME" podman push "$FINAL_IMAGE_NAME" --digestfile=digest echo "digest=$(cat digest)" >> "$GITHUB_OUTPUT" - echo "tag=$TAG" >> "$GITHUB_OUTPUT" - name: Generate artifact attestation uses: actions/attest-build-provenance@v1 with: - subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.build-image.outputs.tag }} + subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} subject-digest: "${{ steps.build-image.outputs.digest }}" push-to-registry: true From 3f560fd29adb2f8e479ce4df9c0c43d1d76bf119 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexis=20M=C3=A9taireau?= Date: Mon, 20 Jan 2025 16:02:18 +0100 Subject: [PATCH 6/9] Add logs --- .github/workflows/release-container-image.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/release-container-image.yml b/.github/workflows/release-container-image.yml index 7177e93..0995261 100644 --- a/.github/workflows/release-container-image.yml +++ b/.github/workflows/release-container-image.yml @@ -29,6 +29,9 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 + - name: Check it's working + run: | + git describe --long --first-parent - name: Login to GitHub Container Registry uses: docker/login-action@v3 From 9aa84a2a81582606647a05c8ad593043ab6f6a6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexis=20M=C3=A9taireau?= Date: Mon, 20 Jan 2025 16:56:24 +0100 Subject: [PATCH 7/9] FIXUP: test --- .github/workflows/release-container-image.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.github/workflows/release-container-image.yml b/.github/workflows/release-container-image.yml index 0995261..13e0d00 100644 --- a/.github/workflows/release-container-image.yml +++ b/.github/workflows/release-container-image.yml @@ -1,6 +1,3 @@ -# This action listens on new tags, generates a new container image -# sign it and upload it to the container registry. - name: Release container image on: push: @@ -29,9 +26,6 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Check it's working - run: | - git describe --long --first-parent - name: Login to GitHub Container Registry uses: docker/login-action@v3 From e0e145838b3815b6924cc5f1efbd79eba05b2293 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexis=20M=C3=A9taireau?= Date: Wed, 22 Jan 2025 15:21:10 +0100 Subject: [PATCH 8/9] Add a script to verify Github attestations --- dev_scripts/registry.py | 240 ++++++++++++++++++ .../independent-container-updates.md | 23 ++ 2 files changed, 263 insertions(+) create mode 100755 dev_scripts/registry.py create mode 100644 docs/developer/independent-container-updates.md diff --git a/dev_scripts/registry.py b/dev_scripts/registry.py new file mode 100755 index 0000000..9b26420 --- /dev/null +++ b/dev_scripts/registry.py @@ -0,0 +1,240 @@ +#!/usr/bin/python + +import hashlib +import re +import shutil +import subprocess +from tempfile import NamedTemporaryFile + +import click +import requests + +DEFAULT_REPO = "freedomofpress/dangerzone" +SIGSTORE_BUNDLE = "application/vnd.dev.sigstore.bundle.v0.3+json" +DOCKER_MANIFEST_DISTRIBUTION = "application/vnd.docker.distribution.manifest.v2+json" +DOCKER_MANIFEST_INDEX = "application/vnd.oci.image.index.v1+json" +OCI_IMAGE_MANIFEST = "application/vnd.oci.image.manifest.v1+json" + + +class RegistryClient: + def __init__(self, registry, org, image): + self._registry = registry + self._org = org + self._image = image + self._auth_token = None + self._base_url = f"https://{registry}" + self._image_url = f"{self._base_url}/v2/{self._org}/{self._image}" + + @property + def image(self): + return f"{self._registry}/{self._org}/{self._image}" + + def get_auth_token(self): + if not self._auth_token: + auth_url = f"{self._base_url}/token" + response = requests.get( + auth_url, + params={ + "service": f"{self._registry}", + "scope": f"repository:{self._org}/{self._image}:pull", + }, + ) + response.raise_for_status() + self._auth_token = response.json()["token"] + return self._auth_token + + def get_auth_header(self): + return {"Authorization": f"Bearer {self.get_auth_token()}"} + + def list_tags(self): + url = f"{self._image_url}/tags/list" + response = requests.get(url, headers=self.get_auth_header()) + response.raise_for_status() + tags = response.json().get("tags", []) + return tags + + def get_manifest(self, tag, extra_headers=None): + """Get manifest information for a specific tag""" + manifest_url = f"{self._image_url}/manifests/{tag}" + headers = { + "Accept": DOCKER_MANIFEST_DISTRIBUTION, + "Authorization": f"Bearer {self.get_auth_token()}", + } + if extra_headers: + headers.update(extra_headers) + + response = requests.get(manifest_url, headers=headers) + response.raise_for_status() + return response + + def list_manifests(self, tag): + return ( + self.get_manifest( + tag, + { + "Accept": DOCKER_MANIFEST_INDEX, + }, + ) + .json() + .get("manifests") + ) + + def get_blob(self, hash): + url = f"{self._image_url}/blobs/{hash}" + response = requests.get( + url, + headers={ + "Authorization": f"Bearer {self.get_auth_token()}", + }, + ) + response.raise_for_status() + return response + + def get_attestation(self, tag): + """ + Retrieve an attestation from a given tag. + + The attestation needs to be attached using the Cosign Bundle + Specification defined at: + + https://github.com/sigstore/cosign/blob/main/specs/BUNDLE_SPEC.md + """ + + def _find_sigstore_bundle_manifest(manifests): + for manifest in manifests: + if manifest["artifactType"] == SIGSTORE_BUNDLE: + return manifest["mediaType"], manifest["digest"] + + def _get_bundle_blob_digest(layers): + for layer in layers: + if layer.get("mediaType") == SIGSTORE_BUNDLE: + return layer["digest"] + + tag_manifest_content = self.get_manifest(tag).content + + # The attestation is available on the same container registry, with a + # specific tag named "sha256-{sha256(manifest)}" + tag_manifest_hash = hashlib.sha256(tag_manifest_content).hexdigest() + + # This will get us a "list" of manifests... + manifests = self.list_manifests(f"sha256-{tag_manifest_hash}") + + # ... from which we want the sigstore bundle + bundle_manifest_mediatype, bundle_manifest_digest = ( + _find_sigstore_bundle_manifest(manifests) + ) + if not bundle_manifest_digest: + raise Error("Not able to find sigstore bundle manifest info") + + bundle_manifest = self.get_manifest( + bundle_manifest_digest, extra_headers={"Accept": bundle_manifest_mediatype} + ).json() + + # From there, we will get the attestation in a blob. + # It will be the first layer listed at this manifest hash location + layers = bundle_manifest.get("layers", []) + + blob_digest = _get_bundle_blob_digest(layers) + bundle = self.get_blob(blob_digest) + return tag_manifest_content, bundle.content + + def verify_attestation(self, image_tag: str, expected_repo: str): + """ + Look up the image attestation to see if the image has been built + on Github runners, and from a given repository. + """ + manifest, bundle = self.get_attestation(image_tag) + + def _write(file, content): + file.write(content) + file.flush() + + # Put the value in files and verify with cosign + with ( + NamedTemporaryFile(mode="wb") as manifest_json, + NamedTemporaryFile(mode="wb") as bundle_json, + ): + _write(manifest_json, manifest) + _write(bundle_json, bundle) + + # Call cosign with the temporary file paths + cmd = [ + "cosign", + "verify-blob-attestation", + "--bundle", + bundle_json.name, + "--new-bundle-format", + "--certificate-oidc-issuer", + "https://token.actions.githubusercontent.com", + "--certificate-identity-regexp", + f"^https://github.com/{expected_repo}/.github/workflows/release-container-image.yml@refs/heads/test/image-publication-cosign", + manifest_json.name, + ] + + result = subprocess.run(cmd, capture_output=True) + if result.returncode != 0: + raise Exception(f"Attestation cannot be verified. {result.stderr}") + return True + + +def parse_image_location(input_string): + """Parses container image location into (registry, namespace, repository, tag)""" + pattern = ( + r"^" + r"(?P[a-zA-Z0-9.-]+)/" + r"(?P[a-zA-Z0-9-]+)/" + r"(?P[^:]+)" + r"(?::(?P[a-zA-Z0-9.-]+))?" + r"$" + ) + match = re.match(pattern, input_string) + if not match: + raise ValueError("Malformed image location") + return match.group("registry", "namespace", "repository", "tag") + + +@click.group() +def main(): + pass + + +@main.command() +@click.argument("image") +def list_tags(image): + registry, org, package, _ = parse_image_location(image) + client = RegistryClient(registry, org, package) + tags = client.list_tags() + click.echo(f"Existing tags for {client.image}") + for tag in tags: + click.echo(tag) + + +@main.command() +@click.argument("image") +@click.option( + "--repo", + default=DEFAULT_REPO, + help="The github repository to check the attestation for", +) +def attest(image: str, repo: str): + """ + Look up the image attestation to see if the image has been built + on Github runners, and from a given repository. + """ + if shutil.which("cosign") is None: + click.echo("The cosign binary is needed but not installed.") + raise click.Abort() + + registry, org, package, tag = parse_image_location(image) + tag = tag or "latest" + + client = RegistryClient(registry, org, package) + verified = client.verify_attestation(tag, repo) + if verified: + click.echo( + f"🎉 The image available at `{client.image}:{tag}` has been built by Github Runners from the `{repo}` repository" + ) + + +if __name__ == "__main__": + main() diff --git a/docs/developer/independent-container-updates.md b/docs/developer/independent-container-updates.md new file mode 100644 index 0000000..25a7d43 --- /dev/null +++ b/docs/developer/independent-container-updates.md @@ -0,0 +1,23 @@ +# Independent Container Updates + +Since version 0.9.0, Dangerzone is able to ship container images independently +from issuing a new release of the software. + +This is useful as images need to be kept updated with the latest security fixes. + +## Nightly images and attestations + +Each night, new images are built and pushed to our container registry, alongside +with a provenance attestation, enabling anybody to ensure that the image has +been originally built by Github CI runners, from a defined source repository (in our case `freedomofpress/dangerzone`). + +To verify the attestations against our expectations, use the following command: +```bash +poetry run ./dev_scripts/registry.py attest ghcr.io/freedomofpress/dangerzone/dangerzone:latest --repo freedomofpress/dangerzone +``` + +In case of sucess, it will report back: + +``` +🎉 The image available at `ghcr.io/freedomofpress/dangerzone/dangerzone:latest` has been built by Github runners from the `freedomofpress/dangerzone` repository. +``` From 13986655fb3b8b5870bc4d58af47ae8a43798c2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexis=20M=C3=A9taireau?= Date: Wed, 22 Jan 2025 16:06:06 +0100 Subject: [PATCH 9/9] Add an utility to retrieve manifest info --- dev_scripts/registry.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/dev_scripts/registry.py b/dev_scripts/registry.py index 9b26420..b688056 100755 --- a/dev_scripts/registry.py +++ b/dev_scripts/registry.py @@ -209,6 +209,16 @@ def list_tags(image): click.echo(tag) +@main.command() +@click.argument("image") +@click.argument("tag") +def get_manifest(image, tag): + registry, org, package, _ = parse_image_location(image) + client = RegistryClient(registry, org, package) + resp = client.get_manifest(tag, extra_headers={"Accept": OCI_IMAGE_MANIFEST}) + click.echo(resp.content) + + @main.command() @click.argument("image") @click.option(