Compare commits

..

No commits in common. "2981ec4450f37c0a92e7954621531bc10f9bbc98" and "aedfc3b9a2270212a95855339b4b1affa1eb5297" have entirely different histories.

6 changed files with 103 additions and 268 deletions

View file

@ -1,162 +0,0 @@
name: Multi-arch build
on:
push:
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 }}

View file

@ -4,82 +4,37 @@ from tempfile import NamedTemporaryFile
from . import cosign 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( def verify(
image_name: str, branch: str, commit: str, repository: str, workflow: str, manifest: bytes, attestation_bundle: bytes, image_tag: str, expected_repo: str
) -> bool: ) -> bool:
""" """
Look up the image attestation to see if the image has been built Look up the image attestation to see if the image has been built
on Github runners, and from a given repository. on Github runners, and from a given repository.
""" """
cosign.ensure_installed() cosign.ensure_installed()
policy = generate_cue_policy(repository, workflow, commit, branch)
# Put the value in files and verify with cosign # Put the value in files and verify with cosign
with ( with (
NamedTemporaryFile(mode="w", suffix=".cue") as policy_f, NamedTemporaryFile(mode="wb") as manifest_json,
NamedTemporaryFile(mode="wb") as attestation_bundle_json,
): ):
policy_f.write(policy) manifest_json.write(manifest)
policy_f.flush() manifest_json.flush()
attestation_bundle_json.write(attestation_bundle)
attestation_bundle_json.flush()
# Call cosign with the temporary file paths # Call cosign with the temporary file paths
cmd = [ cmd = [
"cosign", "cosign",
"verify-attestation", "verify-blob-attestation",
"--type", "--bundle",
"slsaprovenance", attestation_bundle_json.name,
"--policy", "--new-bundle-format",
policy_f.name,
"--certificate-oidc-issuer", "--certificate-oidc-issuer",
"https://token.actions.githubusercontent.com", "https://token.actions.githubusercontent.com",
"--certificate-identity-regexp", "--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]+$", f"^https://github.com/{expected_repo}/.github/workflows/release-container-image.yml@refs/heads/test/image-publication-cosign",
image_name, manifest_json.name,
] ]
result = subprocess.run(cmd, capture_output=True) result = subprocess.run(cmd, capture_output=True)

View file

@ -8,7 +8,6 @@ from ..util import get_resource_path
from . import attestations, errors, log, registry, signatures from . import attestations, errors, log, registry, signatures
DEFAULT_REPOSITORY = "freedomofpress/dangerzone" DEFAULT_REPOSITORY = "freedomofpress/dangerzone"
DEFAULT_BRANCH = "main"
DEFAULT_IMAGE_NAME = "ghcr.io/freedomofpress/dangerzone/dangerzone" DEFAULT_IMAGE_NAME = "ghcr.io/freedomofpress/dangerzone/dangerzone"
PUBKEY_DEFAULT_LOCATION = get_resource_path("freedomofpress-dangerzone-pub.key") PUBKEY_DEFAULT_LOCATION = get_resource_path("freedomofpress-dangerzone-pub.key")
@ -104,52 +103,29 @@ def get_manifest(image: str) -> None:
@main.command() @main.command()
@click.argument("image_name") @click.argument("image")
# 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( @click.option(
"--repository", "--repository",
default=DEFAULT_REPOSITORY, default=DEFAULT_REPOSITORY,
help="The github repository to check the attestation for", help="The github repository to check the attestation for",
) )
@click.option( def attest_provenance(image: str, repository: str) -> None:
"--workflow",
default=".github/workflows/multi_arch_build.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 Look up the image attestation to see if the image has been built
on Github runners, and from a given repository. on Github runners, and from a given repository.
""" """
# TODO: Parse image and make sure it has a tag. Might even check for a digest. # XXX put this inside a module
# parsed = registry.parse_image_location(image) # if shutil.which("cosign") is None:
# click.echo("The cosign binary is needed but not installed.")
# raise click.Abort()
parsed = registry.parse_image_location(image)
manifest, bundle = registry.get_attestation(image)
verified = attestations.verify(image_name, branch, commit, repository, workflow) verified = attestations.verify(manifest, bundle, parsed.tag, repository)
if verified: if verified:
click.echo( click.echo(
f"🎉 Successfully verified image '{image_name}' and its associated claims:" f"🎉 The image available at `{parsed.full_name}` has been built by Github Runners from the `{repository}` repository"
) )
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__": if __name__ == "__main__":

View file

@ -11,11 +11,14 @@ __all__ = [
"get_manifest_hash", "get_manifest_hash",
"list_tags", "list_tags",
"get_manifest", "get_manifest",
"get_attestation",
"parse_image_location", "parse_image_location",
] ]
SIGSTORE_BUNDLE = "application/vnd.dev.sigstore.bundle.v0.3+json" SIGSTORE_BUNDLE = "application/vnd.dev.sigstore.bundle.v0.3+json"
ACCEPT_MANIFESTS_HEADER="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,application/vnd.oci.image.index.v1+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 Image(namedtuple("Image", ["registry", "namespace", "image_name", "tag"])): class Image(namedtuple("Image", ["registry", "namespace", "image_name", "tag"])):
@ -89,14 +92,16 @@ class RegistryClient:
return tags return tags
def get_manifest( def get_manifest(
self, tag: str, self, tag: str, extra_headers: Optional[dict] = None
) -> requests.Response: ) -> requests.Response:
"""Get manifest information for a specific tag""" """Get manifest information for a specific tag"""
manifest_url = f"{self._image_url}/manifests/{tag}" manifest_url = f"{self._image_url}/manifests/{tag}"
headers = { headers = {
"Accept": ACCEPT_MANIFESTS_HEADER, "Accept": DOCKER_MANIFEST_DISTRIBUTION,
"Authorization": f"Bearer {self.get_auth_token()}", "Authorization": f"Bearer {self.get_auth_token()}",
} }
if extra_headers:
headers.update(extra_headers)
response = requests.get(manifest_url, headers=headers) response = requests.get(manifest_url, headers=headers)
response.raise_for_status() response.raise_for_status()
@ -106,6 +111,9 @@ class RegistryClient:
return ( return (
self.get_manifest( self.get_manifest(
tag, tag,
{
"Accept": DOCKER_MANIFEST_INDEX,
},
) )
.json() .json()
.get("manifests") .get("manifests")
@ -130,6 +138,65 @@ class RegistryClient:
return hashlib.sha256(tag_manifest_content).hexdigest() return hashlib.sha256(tag_manifest_content).hexdigest()
def get_attestation(self, tag: str) -> Tuple[bytes, bytes]:
"""
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
Returns a tuple with the tag manifest content and the bundle content.
"""
# FIXME: do not only rely on the first layer
def _find_sigstore_bundle_manifest(
manifests: list,
) -> Tuple[Optional[str], Optional[str]]:
for manifest in manifests:
if manifest["artifactType"] == SIGSTORE_BUNDLE:
return manifest["mediaType"], manifest["digest"]
return None, None
def _get_bundle_blob_digest(layers: list) -> Optional[str]:
for layer in layers:
if layer.get("mediaType") == SIGSTORE_BUNDLE:
return layer["digest"]
return None
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 = self.get_manifest_hash(tag, tag_manifest_content)
# 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 errors.RegistryError("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)
log.info(f"Found sigstore bundle blob digest: {blob_digest}")
if not blob_digest:
raise errors.RegistryError("Not able to find sigstore bundle blob info")
bundle = self.get_blob(blob_digest)
return tag_manifest_content, bundle.content
def get_manifest_hash(image_str: str) -> str: def get_manifest_hash(image_str: str) -> str:
image = parse_image_location(image_str) image = parse_image_location(image_str)
return RegistryClient(image).get_manifest_hash(image.tag) return RegistryClient(image).get_manifest_hash(image.tag)
@ -142,5 +209,10 @@ def list_tags(image_str: str) -> list:
def get_manifest(image_str: str) -> bytes: def get_manifest(image_str: str) -> bytes:
image = parse_image_location(image_str) image = parse_image_location(image_str)
client = RegistryClient(image) client = RegistryClient(image)
resp = client.get_manifest(image.tag) resp = client.get_manifest(image.tag, extra_headers={"Accept": OCI_IMAGE_MANIFEST})
return resp.content return resp.content
def get_attestation(image_str: str) -> Tuple[bytes, bytes]:
image = parse_image_location(image_str)
return RegistryClient(image).get_attestation(image.tag)

View file

@ -302,7 +302,7 @@ def store_signatures(signatures: list[Dict], image_hash: str, pubkey: str) -> No
) )
pubkey_signatures = SIGNATURES_PATH / get_file_hash(pubkey) pubkey_signatures = SIGNATURES_PATH / get_file_hash(pubkey)
pubkey_signatures.mkdir(parents=True, exist_ok=True) pubkey_signatures.mkdir(exist_ok=True)
with open(pubkey_signatures / f"{image_hash}.json", "w") as f: with open(pubkey_signatures / f"{image_hash}.json", "w") as f:
log.info( log.info(

View file

@ -19,14 +19,7 @@ dangerzone-image attest-provenance ghcr.io/freedomofpress/dangerzone/dangerzone
In case of sucess, it will report back: In case of sucess, it will report back:
``` ```
🎉 Successfully verified image 🎉 The image available at `ghcr.io/freedomofpress/dangerzone/dangerzone:latest` has been built by Github runners from the `freedomofpress/dangerzone` repository.
'ghcr.io/apyrgio/dangerzone/dangerzone:20250129-0.8.0-149-gbf2f5ac@sha256:4da441235e84e93518778827a5c5745d532d7a4079886e1647924bee7ef1c14d'
and its associated claims:
- ✅ SLSA Level 3 provenance
- ✅ GitHub repo: apyrgio/dangerzone
- ✅ GitHub actions workflow: .github/workflows/multi_arch_build.yml
- ✅ Git branch: test/multi-arch
- ✅ Git commit: bf2f5accc24bd15a4f5c869a7f0b03b8fe48dfb6
``` ```
## Install updates ## Install updates
@ -34,7 +27,7 @@ and its associated claims:
To check if a new container image has been released, and update your local installation with it, you can use the following commands: To check if a new container image has been released, and update your local installation with it, you can use the following commands:
```bash ```bash
dangerzone-image upgrade ghcr.io/almet/dangerzone/dangerzone ./dev_scripts/dangerzone-image --debug upgrade ghcr.io/almet/dangerzone/dangerzone
``` ```
## Verify local ## Verify local
@ -60,3 +53,4 @@ On the airgapped machine, copy the file and run the following command:
```bash ```bash
dangerzone-image load-archive dz-fa94872.tar dangerzone-image load-archive dz-fa94872.tar
``` ```