From e1bdb75435825105a9a8619e77a2bbcd6fdf38d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexis=20M=C3=A9taireau?= Date: Tue, 11 Feb 2025 18:13:39 +0100 Subject: [PATCH] Add a `dangerzone-image` CLI script It contains utilities to interact with OCI registries, like getting the list of published tags and getting the content of a manifest. It does so via the use of the Docker Registry API v2 [0]. The script has been added to the `dev_scripts`, and is also installed on the system under `dangerzone-image`. [0] https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry --- dangerzone/updater/__init__.py | 3 + dangerzone/updater/cli.py | 42 ++++++++++++ dangerzone/updater/errors.py | 10 +++ dangerzone/updater/registry.py | 116 +++++++++++++++++++++++++++++++++ dev_scripts/dangerzone-image | 13 ++++ pyproject.toml | 1 + 6 files changed, 185 insertions(+) create mode 100644 dangerzone/updater/__init__.py create mode 100644 dangerzone/updater/cli.py create mode 100644 dangerzone/updater/errors.py create mode 100644 dangerzone/updater/registry.py create mode 100755 dev_scripts/dangerzone-image diff --git a/dangerzone/updater/__init__.py b/dangerzone/updater/__init__.py new file mode 100644 index 0000000..3988bf1 --- /dev/null +++ b/dangerzone/updater/__init__.py @@ -0,0 +1,3 @@ +import logging + +log = logging.getLogger(__name__) diff --git a/dangerzone/updater/cli.py b/dangerzone/updater/cli.py new file mode 100644 index 0000000..1c9f85b --- /dev/null +++ b/dangerzone/updater/cli.py @@ -0,0 +1,42 @@ +#!/usr/bin/python + +import logging + +import click + +from . import attestations, errors, log, registry, signatures + +DEFAULT_REPOSITORY = "freedomofpress/dangerzone" +DEFAULT_BRANCH = "main" +DEFAULT_IMAGE_NAME = "ghcr.io/freedomofpress/dangerzone/dangerzone" + + +@click.group() +@click.option("--debug", is_flag=True) +def main(debug: bool) -> None: + if debug: + click.echo("Debug mode enabled") + level = logging.DEBUG + else: + level = logging.INFO + logging.basicConfig(level=level) + + +@main.command() +@click.argument("image") +def list_remote_tags(image: str) -> None: + """List the tags available for a given image.""" + click.echo(f"Existing tags for {image}") + for tag in registry.list_tags(image): + click.echo(tag) + + +@main.command() +@click.argument("image") +def get_manifest(image: str) -> None: + """Retrieves a remote manifest for a given image and displays it.""" + click.echo(registry.get_manifest(image).content) + + +if __name__ == "__main__": + main() diff --git a/dangerzone/updater/errors.py b/dangerzone/updater/errors.py new file mode 100644 index 0000000..1587e73 --- /dev/null +++ b/dangerzone/updater/errors.py @@ -0,0 +1,10 @@ +class UpdaterError(Exception): + pass + + +class ImageNotFound(UpdaterError): + pass + + +class RegistryError(UpdaterError): + pass diff --git a/dangerzone/updater/registry.py b/dangerzone/updater/registry.py new file mode 100644 index 0000000..fe57364 --- /dev/null +++ b/dangerzone/updater/registry.py @@ -0,0 +1,116 @@ +import re +from collections import namedtuple +from hashlib import sha256 +from typing import Dict, Optional, Tuple + +import requests + +from . import errors, log + +__all__ = [ + "get_manifest_digest", + "list_tags", + "get_manifest", + "parse_image_location", +] + +SIGSTORE_BUNDLE = "application/vnd.dev.sigstore.bundle.v0.3+json" +IMAGE_INDEX_MEDIA_TYPE = "application/vnd.oci.image.index.v1+json" +ACCEPT_MANIFESTS_HEADER = ",".join( + [ + "application/vnd.docker.distribution.manifest.v1+json", + "application/vnd.docker.distribution.manifest.v1+prettyjws", + "application/vnd.docker.distribution.manifest.v2+json", + "application/vnd.oci.image.manifest.v1+json", + "application/vnd.docker.distribution.manifest.list.v2+json", + IMAGE_INDEX_MEDIA_TYPE, + ] +) + + +Image = namedtuple("Image", ["registry", "namespace", "image_name", "tag", "digest"]) + + +def parse_image_location(input_string: str) -> Image: + """Parses container image location into an Image namedtuple""" + pattern = ( + r"^" + r"(?P[a-zA-Z0-9.-]+)/" + r"(?P[a-zA-Z0-9-]+)/" + r"(?P[^:@]+)" + r"(?::(?P[a-zA-Z0-9.-]+))?" + r"(?:@(?Psha256:[a-zA-Z0-9]+))?" + r"$" + ) + match = re.match(pattern, input_string) + if not match: + raise ValueError("Malformed image location") + return Image( + registry=match.group("registry"), + namespace=match.group("namespace"), + image_name=match.group("image_name"), + tag=match.group("tag") or "latest", + digest=match.group("digest"), + ) + + +def _get_auth_header(image: Image) -> Dict[str, str]: + auth_url = f"https://{image.registry}/token" + response = requests.get( + auth_url, + params={ + "service": f"{image.registry}", + "scope": f"repository:{image.namespace}/{image.image_name}:pull", + }, + ) + response.raise_for_status() + token = response.json()["token"] + return {"Authorization": f"Bearer {token}"} + + +def _url(image: Image) -> str: + return f"https://{image.registry}/v2/{image.namespace}/{image.image_name}" + + +def list_tags(image_str: str) -> list: + image = parse_image_location(image_str) + url = f"{_url(image)}/tags/list" + response = requests.get(url, headers=_get_auth_header(image)) + response.raise_for_status() + tags = response.json().get("tags", []) + return tags + + +def get_manifest(image_str: str) -> requests.Response: + """Get manifest information for a specific tag""" + image = parse_image_location(image_str) + manifest_url = f"{_url(image)}/manifests/{image.tag}" + headers = { + "Accept": ACCEPT_MANIFESTS_HEADER, + } + headers.update(_get_auth_header(image)) + + response = requests.get(manifest_url, headers=headers) + response.raise_for_status() + return response + + +def list_manifests(image_str: str) -> list: + return get_manifest(image_str).json().get("manifests") + + +def get_blob(image: Image, digest: str) -> requests.Response: + response = requests.get( + f"{_url(image)}/blobs/{digest}", headers=_get_auth_header(image) + ) + response.raise_for_status() + return response + + +def get_manifest_digest( + image_str: str, tag_manifest_content: Optional[bytes] = None +) -> str: + if not tag_manifest_content: + tag_manifest_content = get_manifest(image_str).content + + return sha256(tag_manifest_content).hexdigest() diff --git a/dev_scripts/dangerzone-image b/dev_scripts/dangerzone-image new file mode 100755 index 0000000..5467207 --- /dev/null +++ b/dev_scripts/dangerzone-image @@ -0,0 +1,13 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import os +import sys + +# Load dangerzone module and resources from the source code tree +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +sys.dangerzone_dev = True + +from dangerzone.updater import cli + +cli.main() diff --git a/pyproject.toml b/pyproject.toml index c54bf2d..ddc8ee5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ packaging = "*" [tool.poetry.scripts] dangerzone = 'dangerzone:main' dangerzone-cli = 'dangerzone:main' +dangerzone-image = "dangerzone.updater.cli:main" # Dependencies required for packaging the code on various platforms. [tool.poetry.group.package.dependencies]